From e912d9e485c68ef524467f4ca06df7f636e30d1a Mon Sep 17 00:00:00 2001 From: Samu Date: Sat, 29 Aug 2026 23:19:48 +0200 Subject: [PATCH] feat: item detail with rip override, re-match and remove --- web/src/App.tsx | 2 + web/src/pages/ItemPage.tsx | 206 +++++++++++++++++++++++++++++++++++++ web/test/item.test.tsx | 113 ++++++++++++++++++++ 3 files changed, 321 insertions(+) create mode 100644 web/src/pages/ItemPage.tsx create mode 100644 web/test/item.test.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 0227651..d69c3ad 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import Shell from './shell' import SetupPage from './pages/SetupPage' import LoginPage from './pages/LoginPage' import LibraryPage from './pages/LibraryPage' +import ItemPage from './pages/ItemPage' import ScanPage from './pages/ScanPage' import AddPage from './pages/AddPage' import SettingsPage from './pages/SettingsPage' @@ -34,6 +35,7 @@ export default function App() { } > } /> + } /> } /> } /> } /> diff --git a/web/src/pages/ItemPage.tsx b/web/src/pages/ItemPage.tsx new file mode 100644 index 0000000..b7f6842 --- /dev/null +++ b/web/src/pages/ItemPage.tsx @@ -0,0 +1,206 @@ +import { 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 Cover from '../components/Cover.js' + +export default function ItemPage() { + const { id } = useParams() + const navigate = useNavigate() + const [item, setItem] = useState(null) + const [error, setError] = useState(false) + const [matching, setMatching] = useState(false) + const [albumQuery, setAlbumQuery] = useState('') + const [albums, setAlbums] = useState(null) + const [pickedAlbum, setPickedAlbum] = useState(null) + const [confirmRemove, setConfirmRemove] = useState(false) + + useEffect(() => { + void api + .getItem(Number(id)) + .then(setItem) + .catch(() => setError(true)) + }, [id]) + + function searchAlbums() { + void api + .searchAlbums(albumQuery) + .then((res) => setAlbums(res.albums)) + .catch(() => setAlbums([])) + } + + function applyMatch(albumId: number | null) { + if (!item) return + void api.setMatch(item.id, albumId).then(setItem) + } + + function remove() { + if (!item) return + void api.deleteItem(item.id).then(() => navigate('/library')) + } + + if (error) return

Item not found.

+ if (!item) return

Loading…

+ + return ( +
+
+ +
+

{item.title}

+

{item.artist}

+

+ {[item.year, item.formats.join(', '), item.labels.join(', '), item.catno, item.country] + .filter(Boolean) + .join(' · ')} +

+ {item.genres.length > 0 &&

{item.genres.join(', ')}

} + + View on Discogs ↗ + +
+
+ + {item.ripStatus === 'ripped' ? ( +

+ In your digital collection ✓ + {item.ripOverride !== null && ' (manually set)'} +

+ ) : ( +

Not ripped yet

+ )} + +
+ {item.ripStatus === 'ripped' ? ( + <> + + {item.ripOverride !== null && ( + + )} + + ) : ( + + )} +
+ +
+ + {matching && ( +
+
+ setAlbumQuery(e.target.value)} + placeholder="Search your digital library" + className="min-w-0 flex-1 rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-1.5 text-sm" + /> + +
+ {albums && albums.length === 0 &&

No matches in your library.

} + {albums && albums.length > 0 && ( +
+ {albums.map((a) => ( + + ))} +
+ )} +
+ + +
+
+ )} +
+ + {item.tracklist.length > 0 && ( +
+ Tracklist +
    + {item.tracklist.map((t, i) => ( +
  1. + {t.position} + {t.title} +
  2. + ))} +
+
+ )} + + {item.barcodes.length > 0 && ( +

Barcodes: {item.barcodes.join(', ')}

+ )} + +
+ + ← Back + + {confirmRemove ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/web/test/item.test.tsx b/web/test/item.test.tsx new file mode 100644 index 0000000..5646538 --- /dev/null +++ b/web/test/item.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +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' + +vi.mock('../src/api.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + api: { ...actual.api, getItem: vi.fn(), setRip: vi.fn(), setMatch: vi.fn(), deleteItem: vi.fn(), searchAlbums: vi.fn() }, + } +}) + +import { api } from '../src/api.js' + +const item: Item = { + id: 1, + discogsReleaseId: 1001, + title: 'Motion', + artist: 'The Cinematic Orchestra', + year: 1999, + formats: ['CD'], + genres: ['Electronic'], + labels: ['Ninja Tune'], + tracklist: [{ position: '1', title: 'Overture' }], + catno: 'ZENCD012', + country: 'UK', + artworkUrl: '/artwork/abc.jpg', + barcodes: ['5021592210629'], + dateAdded: '2026-08-29', + ripOverride: null, + ripStatus: 'not_ripped', +} + +beforeEach(() => { + 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() +}) + +function renderItem() { + return render( + + + } /> + library

} /> +
+
+ ) +} + +describe('ItemPage', () => { + it('renders metadata and the rip-status banner', async () => { + renderItem() + await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy()) + expect(screen.getByText('The Cinematic Orchestra')).toBeTruthy() + expect(screen.getByText(/not ripped yet/i)).toBeTruthy() + expect(screen.getByText(/ZENCD012/)).toBeTruthy() + expect(screen.getByText(/5021592210629/)).toBeTruthy() + expect(screen.getByText('Overture')).toBeTruthy() + expect(screen.getByRole('link', { name: /view on discogs/i }).getAttribute('href')).toBe( + 'https://www.discogs.com/release/1001' + ) + }) + + it('rip override: mark ripped, then reset to auto', async () => { + vi.mocked(api.setRip).mockResolvedValue({ ...item, ripOverride: true, ripStatus: 'ripped' } as never) + renderItem() + await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i })) + await waitFor(() => expect(api.setRip).toHaveBeenCalledWith(1, true)) + await waitFor(() => expect(screen.getByText(/in your digital collection/i)).toBeTruthy()) + + vi.mocked(api.setRip).mockResolvedValue(item as never) + await userEvent.click(screen.getByRole('button', { name: /reset to auto/i })) + await waitFor(() => expect(api.setRip).toHaveBeenCalledWith(1, null)) + }) + + it('re-match: search albums and link one', async () => { + vi.mocked(api.searchAlbums).mockResolvedValue({ + albums: [{ id: 77, subsonicId: 'a1', title: 'Motion (Remaster)', artist: 'The Cinematic Orchestra' }], + } as never) + vi.mocked(api.setMatch).mockResolvedValue({ ...item, ripStatus: 'ripped' } as never) + renderItem() + await userEvent.click(await screen.findByRole('button', { name: /re-match/i })) + await userEvent.click(screen.getByRole('button', { name: /^search$/i })) + const albumRadio = await screen.findByRole('radio', { name: /motion \(remaster\)/i }) + await userEvent.click(albumRadio) + await userEvent.click(screen.getByRole('button', { name: /^link$/i })) + await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, 77)) + }) + + it('unlink clears the match', async () => { + vi.mocked(api.setMatch).mockResolvedValue(item as never) + renderItem() + await userEvent.click(await screen.findByRole('button', { name: /re-match/i })) + await userEvent.click(screen.getByRole('button', { name: /^unlink$/i })) + await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, null)) + }) + + it('remove deletes the item and navigates back to the library', async () => { + vi.mocked(api.deleteItem).mockResolvedValue({ ok: true } as never) + renderItem() + await userEvent.click(await screen.findByRole('button', { name: /^remove$/i })) + await userEvent.click(await screen.findByRole('button', { name: /^confirm remove$/i })) + await waitFor(() => expect(api.deleteItem).toHaveBeenCalledWith(1)) + await waitFor(() => expect(screen.getByText('library')).toBeTruthy()) + }) +})