1
0

feat: scan flow page with candidates, confirm view and error guidance

This commit is contained in:
2026-08-29 22:37:01 +02:00
parent 890e7da3a7
commit c76d936f1e
5 changed files with 512 additions and 2 deletions

View File

@@ -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 (
<button
type="button"
onClick={() => onSelect(candidate)}
className="flex w-full items-center gap-3 rounded-xl border border-neutral-800 bg-neutral-900 p-3 text-left transition-colors hover:border-neutral-600"
>
<Cover src={candidate.thumbUrl} alt="" className="size-16 shrink-0" />
<div className="min-w-0">
<p className="truncate font-medium">{candidate.title}</p>
<p className="truncate text-sm text-neutral-400">{candidate.artist}</p>
{meta && <p className="truncate text-xs text-neutral-500">{meta}</p>}
</div>
</button>
)
}

View File

@@ -0,0 +1,25 @@
export default function Cover({
src,
alt,
className = 'size-16',
}: {
src: string | null
alt: string
className?: string
}) {
if (!src) {
return (
<div
className={`flex items-center justify-center rounded-lg bg-neutral-800 text-neutral-600 ${className}`}
aria-label={alt}
role="img"
>
<svg viewBox="0 0 24 24" className="size-1/2" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="3" />
</svg>
</div>
)
}
return <img src={src} alt={alt} loading="lazy" className={`rounded-lg bg-neutral-800 object-cover ${className}`} />
}

View File

@@ -1,3 +1,201 @@
export default function ScanPage() {
return <p className="text-neutral-400">Scanner goes here.</p>
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 (
<div className="space-y-4">
{state.phase === 'scan' && <Scanner enabled onDetect={onDetect} />}
{state.phase === 'looking' && (
<p className="py-8 text-center text-neutral-400">Looking up {state.code}</p>
)}
{state.phase === 'candidates' && (
<div className="space-y-3">
<p className="text-sm text-neutral-400">Which release is it?</p>
{state.candidates.map((c) => (
<CandidateCard key={c.id} candidate={c} onSelect={(cand) => dispatch({ type: 'SELECT', candidate: cand })} />
))}
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
>
Scan another
</button>
</div>
)}
{state.phase === 'confirm' && (
<div className="space-y-4">
{!state.preview && <p className="py-8 text-center text-neutral-400">Checking release</p>}
{state.preview && (
<ConfirmView
code={state.code}
preview={state.preview}
matchAlbumId={state.matchAlbumId}
onSetMatch={(albumId) => dispatch({ type: 'SET_MATCH', albumId })}
onClearMatch={() => dispatch({ type: 'CLEAR_MATCH' })}
onAdd={add}
adding={state.adding}
addError={state.addError}
/>
)}
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
>
Cancel
</button>
</div>
)}
{state.phase === 'added' && (
<div className="space-y-4 text-center">
<Cover src={state.item.artworkUrl} alt="" className="mx-auto size-32" />
<p className="text-lg font-medium">Added to collection </p>
<p className="text-sm text-neutral-400">
{state.item.artist} {state.item.title}
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="flex-1 rounded-xl bg-emerald-500 py-2 font-medium text-neutral-950"
>
Scan another
</button>
<Link
to={`/item/${state.item.id}`}
className="flex-1 rounded-xl border border-neutral-700 py-2 text-center text-sm text-neutral-300"
>
View item
</Link>
</div>
</div>
)}
{state.phase === 'error' && state.kind === 'not_found' && (
<div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium">Nothing found</p>
<p className="text-sm text-neutral-400">
Discogs has no release for barcode {state.code}. Older vinyl often isn't listed by barcode.
</p>
<Link
to={`/add?q=${encodeURIComponent(state.code ?? '')}`}
className="inline-block rounded-xl bg-emerald-500 px-4 py-2 font-medium text-neutral-950"
>
Search manually
</Link>
</div>
)}
{state.phase === 'error' && state.kind === 'no_discogs_token' && (
<div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium">Add your Discogs token first</p>
<Link to="/settings" className="inline-block rounded-xl bg-emerald-500 px-4 py-2 font-medium text-neutral-950">
Settings
</Link>
</div>
)}
{state.phase === 'error' && state.kind === 'rate_limited' && (
<div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium">Slow down</p>
<p className="text-sm text-neutral-400">Discogs is rate limiting us. Try again in a moment.</p>
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="rounded-xl border border-neutral-700 px-4 py-2 text-sm text-neutral-300"
>
Try again
</button>
</div>
)}
{state.phase === 'error' && state.kind === 'server' && (
<div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium">Lookup failed</p>
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="rounded-xl border border-neutral-700 px-4 py-2 text-sm text-neutral-300"
>
Try again
</button>
</div>
)}
</div>
)
}

View File

@@ -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 (
<div className="space-y-4">
<div className="flex gap-4">
<Cover src={release.coverUrl ?? release.thumbUrl} alt="" className="size-28 shrink-0" />
<div className="min-w-0">
<h2 className="text-lg font-semibold leading-tight">{release.title}</h2>
<p className="text-neutral-400">{release.artist}</p>
<p className="text-xs text-neutral-500">
{[release.year, release.formats.join(', '), release.labels[0], release.catno]
.filter(Boolean)
.join(' · ')}
</p>
{code && <p className="mt-1 text-xs text-neutral-500">Barcode {code}</p>}
</div>
</div>
{ripMatch === 'ripped' && (
<p className="rounded-xl bg-emerald-500/10 px-4 py-3 text-sm text-emerald-400">
In your digital collection
</p>
)}
{ripMatch === 'not_ripped' && (
<p className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-400">Not ripped yet</p>
)}
{ripMatch === 'ambiguous' && (
<fieldset className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-400">
<legend className="px-1">Possible matches in your library which one is it?</legend>
<div className="mt-1 space-y-2">
{matchCandidates.map((m) => (
<label key={m.id} className="flex items-center gap-2 text-neutral-200">
<input
type="radio"
name="match"
checked={matchAlbumId === m.id}
onChange={() => onSetMatch(m.id)}
/>
{m.artist} {m.title}
</label>
))}
<label className="flex items-center gap-2 text-neutral-200">
<input type="radio" name="match" checked={matchAlbumId === null} onChange={onClearMatch} />
None of these just add it
</label>
</div>
</fieldset>
)}
{duplicate && (
<p className="rounded-xl bg-red-500/10 px-4 py-3 text-sm text-red-400">
Heads up: this release is already in your collection.
</p>
)}
<details className="rounded-xl border border-neutral-800 bg-neutral-900 p-3">
<summary className="cursor-pointer text-sm text-neutral-300">Tracklist</summary>
<ol className="mt-2 space-y-1 text-sm text-neutral-400">
{release.tracklist.map((t, i) => (
<li key={i} className="flex gap-2">
<span className="w-8 shrink-0 text-neutral-600">{t.position}</span>
<span>{t.title}</span>
</li>
))}
</ol>
</details>
{addError && <p className="text-sm text-red-400">{addError}</p>}
<button
type="button"
onClick={onAdd}
disabled={adding}
className="w-full rounded-xl bg-emerald-500 py-3 font-medium text-neutral-950 disabled:opacity-50"
>
{adding ? 'Adding…' : 'Add to collection'}
</button>
</div>
)
}