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 db = request.server.db
|
||||||
const row = getItem(db, request.user!.id, Number((request.params as { id: string }).id))
|
const row = getItem(db, request.user!.id, Number((request.params as { id: string }).id))
|
||||||
if (!row) return reply.code(404).send({ error: 'not_found' })
|
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) => {
|
app.delete('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
|
|||||||
@@ -56,6 +56,22 @@ describe('loans', () => {
|
|||||||
await app.close()
|
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 () => {
|
it('validates borrower and ownership', async () => {
|
||||||
const { app, cookie, itemId } = await appWithItem()
|
const { app, cookie, itemId } = await appWithItem()
|
||||||
const empty = await app.inject({
|
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 { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { api } from '../api.js'
|
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'
|
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() {
|
export default function ItemPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
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 [error, setError] = useState(false)
|
||||||
const [matching, setMatching] = useState(false)
|
const [matching, setMatching] = useState(false)
|
||||||
const [albumQuery, setAlbumQuery] = useState('')
|
const [albumQuery, setAlbumQuery] = useState('')
|
||||||
@@ -16,13 +32,19 @@ export default function ItemPage() {
|
|||||||
const [confirmRemove, setConfirmRemove] = useState(false)
|
const [confirmRemove, setConfirmRemove] = useState(false)
|
||||||
const [mutationError, setMutationError] = useState<string | null>(null)
|
const [mutationError, setMutationError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const refetch = useCallback(() => {
|
||||||
|
void api.getItem(Number(id)).then(setItem).catch(() => setError(true))
|
||||||
|
}, [id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMutationError(null)
|
setMutationError(null)
|
||||||
void api
|
refetch()
|
||||||
.getItem(Number(id))
|
}, [refetch])
|
||||||
.then(setItem)
|
|
||||||
.catch(() => setError(true))
|
/** Rip/match mutations return a plain Item — keep the detail-only fields. */
|
||||||
}, [id])
|
function applyUpdated(updated: Item) {
|
||||||
|
setItem((prev) => (prev ? { ...updated, matchedAlbum: prev.matchedAlbum, loan: prev.loan } : null))
|
||||||
|
}
|
||||||
|
|
||||||
function searchAlbums() {
|
function searchAlbums() {
|
||||||
void api
|
void api
|
||||||
@@ -39,7 +61,7 @@ export default function ItemPage() {
|
|||||||
void api
|
void api
|
||||||
.setMatch(item.id, albumId)
|
.setMatch(item.id, albumId)
|
||||||
.then((updated) => {
|
.then((updated) => {
|
||||||
setItem(updated)
|
applyUpdated(updated)
|
||||||
setMutationError(null)
|
setMutationError(null)
|
||||||
})
|
})
|
||||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||||
@@ -91,6 +113,19 @@ export default function ItemPage() {
|
|||||||
</p>
|
</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>}
|
{mutationError && <p className="text-sm text-red-400">{mutationError}</p>}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -101,7 +136,7 @@ export default function ItemPage() {
|
|||||||
void api
|
void api
|
||||||
.setRip(item.id, false)
|
.setRip(item.id, false)
|
||||||
.then((updated) => {
|
.then((updated) => {
|
||||||
setItem(updated)
|
applyUpdated(updated)
|
||||||
setMutationError(null)
|
setMutationError(null)
|
||||||
})
|
})
|
||||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||||
@@ -117,7 +152,7 @@ export default function ItemPage() {
|
|||||||
void api
|
void api
|
||||||
.setRip(item.id, true)
|
.setRip(item.id, true)
|
||||||
.then((updated) => {
|
.then((updated) => {
|
||||||
setItem(updated)
|
applyUpdated(updated)
|
||||||
setMutationError(null)
|
setMutationError(null)
|
||||||
})
|
})
|
||||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||||
@@ -134,7 +169,7 @@ export default function ItemPage() {
|
|||||||
void api
|
void api
|
||||||
.setRip(item.id, null)
|
.setRip(item.id, null)
|
||||||
.then((updated) => {
|
.then((updated) => {
|
||||||
setItem(updated)
|
applyUpdated(updated)
|
||||||
setMutationError(null)
|
setMutationError(null)
|
||||||
})
|
})
|
||||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||||
@@ -209,6 +244,43 @@ export default function ItemPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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 && (
|
{item.tracklist.length > 0 && (
|
||||||
<details className="rounded-xl border border-neutral-800 bg-neutral-900 p-3" open>
|
<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>
|
<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 userEvent from '@testing-library/user-event'
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||||
import ItemPage from '../src/pages/ItemPage.js'
|
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) => {
|
vi.mock('../src/api.js', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('../src/api.js')>()
|
const actual = await importOriginal<typeof import('../src/api.js')>()
|
||||||
return {
|
return {
|
||||||
...actual,
|
...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'
|
import { api } from '../src/api.js'
|
||||||
|
|
||||||
const item: Item = {
|
const item: ItemDetail = {
|
||||||
id: 1,
|
id: 1,
|
||||||
discogsReleaseId: 1001,
|
discogsReleaseId: 1001,
|
||||||
title: 'Motion',
|
title: 'Motion',
|
||||||
@@ -32,24 +44,37 @@ const item: Item = {
|
|||||||
dateAdded: '2026-08-29',
|
dateAdded: '2026-08-29',
|
||||||
ripOverride: null,
|
ripOverride: null,
|
||||||
ripStatus: 'not_ripped',
|
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(() => {
|
beforeEach(() => {
|
||||||
|
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
|
||||||
|
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
|
||||||
vi.mocked(api.getItem).mockReset()
|
vi.mocked(api.getItem).mockReset()
|
||||||
vi.mocked(api.getItem).mockResolvedValue(item as never)
|
vi.mocked(api.getItem).mockResolvedValue(item as never)
|
||||||
vi.mocked(api.setRip).mockReset()
|
vi.mocked(api.setRip).mockReset()
|
||||||
vi.mocked(api.setMatch).mockReset()
|
vi.mocked(api.setMatch).mockReset()
|
||||||
vi.mocked(api.deleteItem).mockReset()
|
vi.mocked(api.deleteItem).mockReset()
|
||||||
vi.mocked(api.searchAlbums).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() {
|
function renderItem() {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={['/item/1']}>
|
<MemoryRouter initialEntries={['/item/1']}>
|
||||||
<Routes>
|
<PlayerProvider>
|
||||||
<Route path="/item/:id" element={<ItemPage />} />
|
<Routes>
|
||||||
<Route path="/library" element={<p>library</p>} />
|
<Route path="/item/:id" element={<ItemPage />} />
|
||||||
</Routes>
|
<Route path="/library" element={<p>library</p>} />
|
||||||
|
</Routes>
|
||||||
|
</PlayerProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -142,4 +167,43 @@ describe('ItemPage', () => {
|
|||||||
await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i }))
|
await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i }))
|
||||||
await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy())
|
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