feat: play button, last played, loan lend/return on item page
This commit is contained in:
@@ -179,7 +179,14 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise<vo
|
||||
const db = request.server.db
|
||||
const row = getItem(db, request.user!.id, Number((request.params as { id: string }).id))
|
||||
if (!row) return reply.code(404).send({ error: 'not_found' })
|
||||
return { ...rowToItem(db, row), matchedAlbum: findMatchedAlbum(db, request.user!.id, row.id) }
|
||||
const loan = db
|
||||
.prepare('SELECT id, borrower, lent_at FROM loans WHERE item_id = ? AND returned_at IS NULL')
|
||||
.get(row.id) as { id: number; borrower: string; lent_at: string } | undefined
|
||||
return {
|
||||
...rowToItem(db, row),
|
||||
matchedAlbum: findMatchedAlbum(db, request.user!.id, row.id),
|
||||
loan: loan ? { id: loan.id, borrower: loan.borrower, lentAt: loan.lent_at } : null,
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => {
|
||||
|
||||
@@ -56,6 +56,22 @@ describe('loans', () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('detail route reports the active loan, null before lending', async () => {
|
||||
const { app, cookie, itemId } = await appWithItem()
|
||||
const before = await app.inject({ method: 'GET', url: `/api/collection/${itemId}`, ...auth(cookie) })
|
||||
expect(before.json().loan).toBeNull()
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/collection/${itemId}/loan`,
|
||||
...auth(cookie),
|
||||
payload: { borrower: 'Bob' },
|
||||
})
|
||||
const after = await app.inject({ method: 'GET', url: `/api/collection/${itemId}`, ...auth(cookie) })
|
||||
expect(after.json().loan).toMatchObject({ borrower: 'Bob' })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('validates borrower and ownership', async () => {
|
||||
const { app, cookie, itemId } = await appWithItem()
|
||||
const empty = await app.inject({
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api.js'
|
||||
import type { DigitalAlbum, Item } from '../types.js'
|
||||
import { usePlayer } from '../player/PlayerContext.js'
|
||||
import type { DigitalAlbum, Item, ItemDetail } from '../types.js'
|
||||
import Cover from '../components/Cover.js'
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
||||
if (secs < 60) return 'just now'
|
||||
const mins = Math.floor(secs / 60)
|
||||
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`
|
||||
const months = Math.floor(days / 30)
|
||||
if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`
|
||||
return `${Math.floor(months / 12)} year${months >= 24 ? 's' : ''} ago`
|
||||
}
|
||||
|
||||
export default function ItemPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [item, setItem] = useState<Item | null>(null)
|
||||
const { load } = usePlayer()
|
||||
const [item, setItem] = useState<ItemDetail | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [matching, setMatching] = useState(false)
|
||||
const [albumQuery, setAlbumQuery] = useState('')
|
||||
@@ -16,13 +32,19 @@ export default function ItemPage() {
|
||||
const [confirmRemove, setConfirmRemove] = useState(false)
|
||||
const [mutationError, setMutationError] = useState<string | null>(null)
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
void api.getItem(Number(id)).then(setItem).catch(() => setError(true))
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
setMutationError(null)
|
||||
void api
|
||||
.getItem(Number(id))
|
||||
.then(setItem)
|
||||
.catch(() => setError(true))
|
||||
}, [id])
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
/** Rip/match mutations return a plain Item — keep the detail-only fields. */
|
||||
function applyUpdated(updated: Item) {
|
||||
setItem((prev) => (prev ? { ...updated, matchedAlbum: prev.matchedAlbum, loan: prev.loan } : null))
|
||||
}
|
||||
|
||||
function searchAlbums() {
|
||||
void api
|
||||
@@ -39,7 +61,7 @@ export default function ItemPage() {
|
||||
void api
|
||||
.setMatch(item.id, albumId)
|
||||
.then((updated) => {
|
||||
setItem(updated)
|
||||
applyUpdated(updated)
|
||||
setMutationError(null)
|
||||
})
|
||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||
@@ -91,6 +113,19 @@ export default function ItemPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{item.ripStatus === 'ripped' && item.matchedAlbum && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load({ id: item.matchedAlbum!.subsonicId, title: item.title, artist: item.artist })}
|
||||
className="w-full rounded-xl bg-emerald-500 py-2.5 font-medium text-neutral-950"
|
||||
>
|
||||
▶ Play album
|
||||
</button>
|
||||
)}
|
||||
{item.matchedAlbum?.lastPlayedAt && (
|
||||
<p className="text-xs text-neutral-500">Last played {timeAgo(item.matchedAlbum.lastPlayedAt)}</p>
|
||||
)}
|
||||
|
||||
{mutationError && <p className="text-sm text-red-400">{mutationError}</p>}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -101,7 +136,7 @@ export default function ItemPage() {
|
||||
void api
|
||||
.setRip(item.id, false)
|
||||
.then((updated) => {
|
||||
setItem(updated)
|
||||
applyUpdated(updated)
|
||||
setMutationError(null)
|
||||
})
|
||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||
@@ -117,7 +152,7 @@ export default function ItemPage() {
|
||||
void api
|
||||
.setRip(item.id, true)
|
||||
.then((updated) => {
|
||||
setItem(updated)
|
||||
applyUpdated(updated)
|
||||
setMutationError(null)
|
||||
})
|
||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||
@@ -134,7 +169,7 @@ export default function ItemPage() {
|
||||
void api
|
||||
.setRip(item.id, null)
|
||||
.then((updated) => {
|
||||
setItem(updated)
|
||||
applyUpdated(updated)
|
||||
setMutationError(null)
|
||||
})
|
||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||
@@ -209,6 +244,43 @@ export default function ItemPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-800 bg-neutral-900 p-3">
|
||||
{item.loan ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-neutral-300">
|
||||
Out to {item.loan.borrower} since {new Date(item.loan.lentAt).toLocaleDateString()}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void api.returnLoan(item.loan!.id).then(refetch)}
|
||||
className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||
>
|
||||
Mark returned
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
const borrower = (e.currentTarget.elements.namedItem('borrower') as HTMLInputElement).value
|
||||
void api.lendItem(item.id, borrower).then(refetch)
|
||||
}}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<input
|
||||
name="borrower"
|
||||
aria-label="Borrower"
|
||||
placeholder="Lend to…"
|
||||
required
|
||||
className="min-w-0 flex-1 rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button type="submit" className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300">
|
||||
Lend
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{item.tracklist.length > 0 && (
|
||||
<details className="rounded-xl border border-neutral-800 bg-neutral-900 p-3" open>
|
||||
<summary className="cursor-pointer text-sm text-neutral-300">Tracklist</summary>
|
||||
|
||||
@@ -3,19 +3,31 @@ import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import ItemPage from '../src/pages/ItemPage.js'
|
||||
import type { Item } from '../src/types.js'
|
||||
import { PlayerProvider } from '../src/player/PlayerContext.js'
|
||||
import type { ItemDetail, MatchedAlbum } from '../src/types.js'
|
||||
|
||||
vi.mock('../src/api.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/api.js')>()
|
||||
return {
|
||||
...actual,
|
||||
api: { ...actual.api, getItem: vi.fn(), setRip: vi.fn(), setMatch: vi.fn(), deleteItem: vi.fn(), searchAlbums: vi.fn() },
|
||||
api: {
|
||||
...actual.api,
|
||||
getItem: vi.fn(),
|
||||
setRip: vi.fn(),
|
||||
setMatch: vi.fn(),
|
||||
deleteItem: vi.fn(),
|
||||
searchAlbums: vi.fn(),
|
||||
getAlbumTracks: vi.fn(),
|
||||
markPlayed: vi.fn(),
|
||||
lendItem: vi.fn(),
|
||||
returnLoan: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { api } from '../src/api.js'
|
||||
|
||||
const item: Item = {
|
||||
const item: ItemDetail = {
|
||||
id: 1,
|
||||
discogsReleaseId: 1001,
|
||||
title: 'Motion',
|
||||
@@ -32,24 +44,37 @@ const item: Item = {
|
||||
dateAdded: '2026-08-29',
|
||||
ripOverride: null,
|
||||
ripStatus: 'not_ripped',
|
||||
matchedAlbum: null,
|
||||
loan: null,
|
||||
}
|
||||
|
||||
const matched: MatchedAlbum = { id: 77, subsonicId: 'alb-1', lastPlayedAt: '2026-09-01T10:00:00Z' }
|
||||
const rippedItem: ItemDetail = { ...item, ripStatus: 'ripped', matchedAlbum: matched }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
|
||||
vi.mocked(api.getItem).mockReset()
|
||||
vi.mocked(api.getItem).mockResolvedValue(item as never)
|
||||
vi.mocked(api.setRip).mockReset()
|
||||
vi.mocked(api.setMatch).mockReset()
|
||||
vi.mocked(api.deleteItem).mockReset()
|
||||
vi.mocked(api.searchAlbums).mockReset()
|
||||
vi.mocked(api.getAlbumTracks).mockReset()
|
||||
vi.mocked(api.markPlayed).mockReset()
|
||||
vi.mocked(api.lendItem).mockReset()
|
||||
vi.mocked(api.returnLoan).mockReset()
|
||||
})
|
||||
|
||||
function renderItem() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/item/1']}>
|
||||
<Routes>
|
||||
<Route path="/item/:id" element={<ItemPage />} />
|
||||
<Route path="/library" element={<p>library</p>} />
|
||||
</Routes>
|
||||
<PlayerProvider>
|
||||
<Routes>
|
||||
<Route path="/item/:id" element={<ItemPage />} />
|
||||
<Route path="/library" element={<p>library</p>} />
|
||||
</Routes>
|
||||
</PlayerProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
@@ -142,4 +167,43 @@ describe('ItemPage', () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i }))
|
||||
await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy())
|
||||
})
|
||||
|
||||
it('shows Play for a ripped item with a matched album and loads the player', async () => {
|
||||
vi.mocked(api.getItem).mockResolvedValue(rippedItem as never)
|
||||
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'alb-1', title: 'Motion', artist: 'TCO', tracks: [] } as never)
|
||||
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
|
||||
renderItem()
|
||||
const play = await screen.findByRole('button', { name: /play album/i })
|
||||
await userEvent.click(play)
|
||||
await waitFor(() => expect(api.getAlbumTracks).toHaveBeenCalledWith('alb-1'))
|
||||
await waitFor(() => expect(api.markPlayed).toHaveBeenCalledWith('alb-1'))
|
||||
})
|
||||
|
||||
it('hides Play when unmatched or not ripped', async () => {
|
||||
renderItem()
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
|
||||
expect(screen.queryByRole('button', { name: /play album/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows last played under the rip banner', async () => {
|
||||
vi.mocked(api.getItem).mockResolvedValue(rippedItem as never)
|
||||
renderItem()
|
||||
expect(await screen.findByText(/last played/i)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lend and return flow', async () => {
|
||||
vi.mocked(api.getItem)
|
||||
.mockResolvedValueOnce(rippedItem as never)
|
||||
.mockResolvedValueOnce({ ...rippedItem, loan: { id: 9, borrower: 'Bob', lentAt: '2026-09-03' } } as never)
|
||||
vi.mocked(api.lendItem).mockResolvedValue({ id: 9, itemId: 1, borrower: 'Bob', lentAt: '2026-09-03', returnedAt: null } as never)
|
||||
renderItem()
|
||||
await userEvent.type(await screen.findByLabelText(/borrower/i), 'Bob')
|
||||
await userEvent.click(screen.getByRole('button', { name: /^lend$/i }))
|
||||
await waitFor(() => expect(api.lendItem).toHaveBeenCalledWith(1, 'Bob'))
|
||||
|
||||
vi.mocked(api.returnLoan).mockResolvedValue({ ok: true } as never)
|
||||
await waitFor(() => expect(screen.getByText(/out to bob/i)).toBeTruthy())
|
||||
await userEvent.click(screen.getByRole('button', { name: /mark returned/i }))
|
||||
await waitFor(() => expect(api.returnLoan).toHaveBeenCalledWith(9))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user