1
0
Files
record-shop/web/src/pages/AddPage.tsx

207 lines
7.4 KiB
TypeScript
Raw Normal View History

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_auth') return 'discogs_auth'
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 (
<div className="space-y-4">
{state.phase === 'scan' && (
<form
onSubmit={(e) => {
e.preventDefault()
search(q)
}}
className="space-y-3"
>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Artist and title"
className="w-full rounded-xl border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
/>
<div className="flex gap-2">
<label className="sr-only" htmlFor="format">
Format
</label>
<select
id="format"
value={format}
onChange={(e) => setFormat(e.target.value as (typeof FORMATS)[number] | '')}
className="rounded-xl border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
>
<option value="">Any format</option>
{FORMATS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
<button type="submit" className="flex-1 rounded-xl bg-emerald-500 py-2 font-medium text-neutral-950">
Search
</button>
</div>
<Link to="/scan" className="block text-center text-sm text-neutral-400">
or scan a barcode
</Link>
</form>
)}
{state.phase === 'looking' && <p className="py-8 text-center text-neutral-400">Searching</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 })} />
))}
</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={null}
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"
>
Back to search
</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>
<Link className="block text-sm text-emerald-400" to={`/item/${state.item.id}`}>
View item
</Link>
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
>
Add another
</button>
</div>
)}
{state.phase === 'error' && (
<div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium">
{state.kind === 'not_found'
? 'Nothing found'
: state.kind === 'discogs_auth'
? 'Discogs rejected your token'
: 'Search failed'}
</p>
<p className="text-sm text-neutral-400">
{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 === 'discogs_auth' && 'Check your Discogs token in Settings.'}
{state.kind === 'rate_limited' && 'Discogs is rate limiting us. Try again shortly.'}
</p>
<button
type="button"
onClick={() => dispatch({ type: 'RESET' })}
className="rounded-xl border border-neutral-700 px-4 py-2 text-sm text-neutral-300"
>
Back
</button>
</div>
)}
</div>
)
}