1
0

feat: play button, last played, loan lend/return on item page

This commit is contained in:
2026-09-03 22:45:20 +02:00
parent b2711b2b79
commit ece18d125b
4 changed files with 179 additions and 20 deletions

View File

@@ -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>

View File

@@ -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))
})
})