diff --git a/web/src/pages/AddPage.tsx b/web/src/pages/AddPage.tsx index 93c0d4d..8ba6301 100644 --- a/web/src/pages/AddPage.tsx +++ b/web/src/pages/AddPage.tsx @@ -1,3 +1,200 @@ -export default function AddPage() { - return
Search goes here.
+import { useCallback, useEffect, useReducer, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { api, ApiError } from '../api.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' +} + +const FORMATS = ['Vinyl', 'CD', 'Cassette'] as const + +export default function AddPage() { + const [params] = useSearchParams() + const [q, setQ] = useState(params.get('q') ?? '') + const [format, setFormat] = useState<(typeof FORMATS)[number] | ''>('') + const [state, dispatch] = useReducer(scanReducer, INITIAL_SCAN_STATE) + + const search = useCallback( + (query: string) => { + if (!query.trim()) return + dispatch({ type: 'DETECT', code: query.trim() }) + void (async () => { + try { + const { candidates } = await api.lookupSearch(query.trim(), format || undefined) + if (candidates.length === 0) { + dispatch({ type: 'NOT_FOUND', code: query.trim() }) + } else { + dispatch({ type: 'CANDIDATES', code: query.trim(), candidates }) + } + } catch (err) { + dispatch({ type: 'ERROR', kind: toErrorKind(err), code: query.trim(), source: 'lookup' }) + } + })() + }, + [format] + ) + + // 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, candidateId }) + } catch (err) { + dispatch({ type: 'ERROR', kind: toErrorKind(err), code: null, source: 'preview', candidateId }) + } + })() + }, [state]) + + const add = useCallback(() => { + if (state.phase !== 'confirm' || !state.preview || state.adding) return + const { candidate, matchAlbumId } = state + dispatch({ type: 'ADD_START' }) + void (async () => { + try { + const item = await api.addToCollection({ + releaseId: candidate.id, + ...(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 ( +Searching…
} + + {state.phase === 'candidates' && ( +Which release is it?
+ {state.candidates.map((c) => ( +Checking release…
} + {state.preview && ( +Added to collection ✓
+ + View item + + ++ {state.kind === 'not_found' ? 'Nothing found' : 'Search failed'} +
++ {state.kind === 'not_found' && 'Try different spelling, or add the year.'} + {state.kind === 'no_discogs_token' && 'Add your Discogs token in Settings first.'} + {state.kind === 'rate_limited' && 'Discogs is rate limiting us. Try again shortly.'} +
+ +