diff --git a/web/src/components/CandidateCard.tsx b/web/src/components/CandidateCard.tsx new file mode 100644 index 0000000..bef43a0 --- /dev/null +++ b/web/src/components/CandidateCard.tsx @@ -0,0 +1,26 @@ +import type { Candidate } from '../types.js' +import Cover from './Cover.js' + +export default function CandidateCard({ + candidate, + onSelect, +}: { + candidate: Candidate + onSelect: (candidate: Candidate) => void +}) { + const meta = [candidate.year, candidate.formats[0], candidate.labels[0]].filter(Boolean).join(' · ') + return ( + + ) +} diff --git a/web/src/components/Cover.tsx b/web/src/components/Cover.tsx new file mode 100644 index 0000000..f7fdbec --- /dev/null +++ b/web/src/components/Cover.tsx @@ -0,0 +1,25 @@ +export default function Cover({ + src, + alt, + className = 'size-16', +}: { + src: string | null + alt: string + className?: string +}) { + if (!src) { + return ( +
+ + + + +
+ ) + } + return {alt} +} diff --git a/web/src/pages/ScanPage.tsx b/web/src/pages/ScanPage.tsx index 711e97d..28cf317 100644 --- a/web/src/pages/ScanPage.tsx +++ b/web/src/pages/ScanPage.tsx @@ -1,3 +1,201 @@ -export default function ScanPage() { - return

Scanner goes here.

+import { useCallback, useEffect, useReducer } from 'react' +import { Link } from 'react-router-dom' +import { api, ApiError } from '../api.js' +import Scanner from '../components/Scanner.js' +import CandidateCard from '../components/CandidateCard.js' +import ConfirmView from '../scan/ConfirmView.js' +import Cover from '../components/Cover.js' +import { scanReducer, INITIAL_SCAN_STATE, type ScanErrorKind } from '../scan/reducer.js' + +function toErrorKind(err: unknown): ScanErrorKind { + if (err instanceof ApiError) { + if (err.code === 'not_found') return 'not_found' + if (err.code === 'no_discogs_token') return 'no_discogs_token' + if (err.code === 'discogs_rate_limited' || err.status === 429) return 'rate_limited' + } + return 'server' +} + +export default function ScanPage() { + const [state, dispatch] = useReducer(scanReducer, INITIAL_SCAN_STATE) + + const onDetect = useCallback((code: string) => { + dispatch({ type: 'DETECT', code }) + void (async () => { + try { + const { candidates } = await api.lookupBarcode(code) + if (candidates.length === 0) { + dispatch({ type: 'NOT_FOUND', code }) + } else { + dispatch({ type: 'CANDIDATES', code, candidates }) + } + } catch (err) { + dispatch({ type: 'ERROR', kind: toErrorKind(err), code }) + } + })() + }, []) + + // Load the release preview once a candidate is selected. + useEffect(() => { + if (state.phase !== 'confirm' || state.preview) return + const candidateId = state.candidate.id + void (async () => { + try { + const preview = await api.getReleasePreview(candidateId) + dispatch({ type: 'PREVIEW', preview }) + } catch (err) { + dispatch({ type: 'ERROR', kind: toErrorKind(err), code: null }) + } + })() + }, [state]) + + const add = useCallback(() => { + if (state.phase !== 'confirm' || !state.preview || state.adding) return + const { candidate, code, matchAlbumId } = state + void (async () => { + try { + const item = await api.addToCollection({ + releaseId: candidate.id, + ...(code ? { barcode: code } : {}), + ...(matchAlbumId !== null ? { matchAlbumId } : {}), + }) + dispatch({ type: 'ADDED', item }) + } catch (err) { + const message = + err instanceof ApiError + ? err.code === 'duplicate' + ? 'Already in your collection.' + : err.detail ?? err.code + : 'Something went wrong' + dispatch({ type: 'ADD_ERROR', message }) + } + })() + }, [state]) + + return ( +
+ {state.phase === 'scan' && } + + {state.phase === 'looking' && ( +

Looking up {state.code}…

+ )} + + {state.phase === 'candidates' && ( +
+

Which release is it?

+ {state.candidates.map((c) => ( + dispatch({ type: 'SELECT', candidate: cand })} /> + ))} + +
+ )} + + {state.phase === 'confirm' && ( +
+ {!state.preview &&

Checking release…

} + {state.preview && ( + dispatch({ type: 'SET_MATCH', albumId })} + onClearMatch={() => dispatch({ type: 'CLEAR_MATCH' })} + onAdd={add} + adding={state.adding} + addError={state.addError} + /> + )} + +
+ )} + + {state.phase === 'added' && ( +
+ +

Added to collection ✓

+

+ {state.item.artist} — {state.item.title} +

+
+ + + View item + +
+
+ )} + + {state.phase === 'error' && state.kind === 'not_found' && ( +
+

Nothing found

+

+ Discogs has no release for barcode {state.code}. Older vinyl often isn't listed by barcode. +

+ + Search manually + +
+ )} + + {state.phase === 'error' && state.kind === 'no_discogs_token' && ( +
+

Add your Discogs token first

+ + Settings + +
+ )} + + {state.phase === 'error' && state.kind === 'rate_limited' && ( +
+

Slow down

+

Discogs is rate limiting us. Try again in a moment.

+ +
+ )} + + {state.phase === 'error' && state.kind === 'server' && ( +
+

Lookup failed

+ +
+ )} +
+ ) } diff --git a/web/src/scan/ConfirmView.tsx b/web/src/scan/ConfirmView.tsx new file mode 100644 index 0000000..5ad1856 --- /dev/null +++ b/web/src/scan/ConfirmView.tsx @@ -0,0 +1,100 @@ +import type { ReleasePreview } from '../types.js' +import Cover from '../components/Cover.js' + +export default function ConfirmView({ + code, + preview, + matchAlbumId, + onSetMatch, + onClearMatch, + onAdd, + adding, + addError, +}: { + code: string | null + preview: ReleasePreview + matchAlbumId: number | null + onSetMatch: (albumId: number) => void + onClearMatch: () => void + onAdd: () => void + adding: boolean + addError: string | null +}) { + const { release, duplicate, ripMatch, matchCandidates } = preview + return ( +
+
+ +
+

{release.title}

+

{release.artist}

+

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

+ {code &&

Barcode {code}

} +
+
+ + {ripMatch === 'ripped' && ( +

+ In your digital collection ✓ +

+ )} + {ripMatch === 'not_ripped' && ( +

Not ripped yet

+ )} + {ripMatch === 'ambiguous' && ( +
+ Possible matches in your library — which one is it? +
+ {matchCandidates.map((m) => ( + + ))} + +
+
+ )} + + {duplicate && ( +

+ Heads up: this release is already in your collection. +

+ )} + +
+ Tracklist +
    + {release.tracklist.map((t, i) => ( +
  1. + {t.position} + {t.title} +
  2. + ))} +
+
+ + {addError &&

{addError}

} + +
+ ) +} diff --git a/web/test/scanPage.test.tsx b/web/test/scanPage.test.tsx new file mode 100644 index 0000000..1aeb307 --- /dev/null +++ b/web/test/scanPage.test.tsx @@ -0,0 +1,161 @@ +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 } from 'react-router-dom' +import ScanPage from '../src/pages/ScanPage.js' +import type { Candidate, ReleasePreview } from '../src/types.js' + +vi.mock('../src/components/Scanner.js', () => ({ + default: ({ onDetect }: { onDetect: (code: string) => void }) => ( + + ), +})) + +vi.mock('../src/api.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + api: { + ...actual.api, + lookupBarcode: vi.fn(), + getReleasePreview: vi.fn(), + addToCollection: vi.fn(), + }, + } +}) + +import { api, ApiError } from '../src/api.js' + +const candidate: Candidate = { + id: 1001, + artist: 'The Cinematic Orchestra', + title: 'Motion', + year: 1999, + formats: ['CD'], + labels: ['Ninja Tune'], + country: 'UK', + catno: 'ZENCD012', + thumbUrl: null, +} + +const preview: ReleasePreview = { + release: { ...candidate, genres: [], tracklist: [{ position: '1', title: 'Overture' }], coverUrl: null, barcodes: ['5021592210629'] }, + duplicate: false, + ripMatch: 'not_ripped', + matchCandidates: [], +} + +// The api module is mocked directly, so mocks resolve with parsed bodies. +function jsonOk(body: unknown) { + return Promise.resolve(body) +} + +beforeEach(() => { + vi.mocked(api.lookupBarcode).mockReset() + vi.mocked(api.getReleasePreview).mockReset() + vi.mocked(api.addToCollection).mockReset() +}) + +function renderScan() { + return render( + + + + ) +} + +describe('ScanPage flow', () => { + it('scan → candidates → confirm → added', async () => { + vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never) + vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never) + vi.mocked(api.addToCollection).mockResolvedValue( + jsonOk({ + id: 1, + discogsReleaseId: 1001, + title: 'Motion', + artist: 'The Cinematic Orchestra', + year: 1999, + formats: ['CD'], + genres: [], + labels: ['Ninja Tune'], + tracklist: [], + catno: 'ZENCD012', + country: 'UK', + artworkUrl: null, + barcodes: ['5021592210629'], + dateAdded: '2026-08-29', + ripOverride: null, + ripStatus: 'not_ripped', + }) as never + ) + + renderScan() + await userEvent.click(screen.getByRole('button', { name: 'fake-scan' })) + + await waitFor(() => expect(screen.getByRole('button', { name: /motion/i })).toBeTruthy()) + await userEvent.click(screen.getByRole('button', { name: /motion/i })) + + await waitFor(() => expect(screen.getByText('Not ripped yet')).toBeTruthy()) + expect(screen.getByText('Overture')).toBeTruthy() + + await userEvent.click(screen.getByRole('button', { name: /add to collection/i })) + await waitFor(() => expect(screen.getByText(/added to collection/i)).toBeTruthy()) + expect(api.addToCollection).toHaveBeenCalledWith({ + releaseId: 1001, + barcode: '5021592210629', + }) + }) + + it('shows not-found guidance with a manual-search escape hatch', async () => { + vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(404, 'not_found')) + renderScan() + await userEvent.click(screen.getByRole('button', { name: 'fake-scan' })) + await waitFor(() => expect(screen.getByText(/nothing found/i)).toBeTruthy()) + expect(screen.getByRole('link', { name: /search manually/i }).getAttribute('href')).toBe( + '/add?q=5021592210629' + ) + }) + + it('sends the picked match candidate for ambiguous rip matches', async () => { + vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never) + vi.mocked(api.getReleasePreview).mockResolvedValue( + jsonOk({ + ...preview, + ripMatch: 'ambiguous', + matchCandidates: [{ id: 77, title: 'Motion', artist: 'Somebody Else' }], + }) as never + ) + vi.mocked(api.addToCollection).mockResolvedValue(jsonOk({}) as never) + + renderScan() + await userEvent.click(screen.getByRole('button', { name: 'fake-scan' })) + await userEvent.click(await screen.findByRole('button', { name: /motion/i })) + await waitFor(() => expect(screen.getByRole('radio', { name: /somebody else/i })).toBeTruthy()) + await userEvent.click(screen.getByRole('radio', { name: /somebody else/i })) + await userEvent.click(screen.getByRole('button', { name: /add to collection/i })) + await waitFor(() => expect(api.addToCollection).toHaveBeenCalledWith({ + releaseId: 1001, + barcode: '5021592210629', + matchAlbumId: 77, + })) + }) + + it('shows a duplicate warning from the preview', async () => { + vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never) + vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk({ ...preview, duplicate: true }) as never) + renderScan() + await userEvent.click(screen.getByRole('button', { name: 'fake-scan' })) + await userEvent.click(await screen.findByRole('button', { name: /motion/i })) + await waitFor(() => expect(screen.getByText(/already in your collection/i)).toBeTruthy()) + }) + + it('links to settings when no discogs token is configured', async () => { + vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(409, 'no_discogs_token')) + renderScan() + await userEvent.click(screen.getByRole('button', { name: 'fake-scan' })) + await waitFor(() => expect(screen.getByText(/discogs token/i)).toBeTruthy()) + expect(screen.getByRole('link', { name: /settings/i }).getAttribute('href')).toBe('/settings') + }) +})