1
0

Compare commits

..

19 Commits

Author SHA1 Message Date
3fa0b02560 docs: record plan 2 post-implementation decisions 2026-09-03 19:28:30 +02:00
21a4c69972 fix: reset-to-auto in both rip branches, visible mutation failures, distinct invalid-token error 2026-09-03 19:24:57 +02:00
f2fff1d7be feat: docker builds the web app, full-stack serving verified 2026-09-03 19:05:57 +02:00
c1852435c8 feat: pwa manifest, icon and minimal service worker 2026-09-03 18:57:31 +02:00
3efde1a828 fix: sync-now feedback and polling, preserve stored subsonic password, fix placeholder 2026-08-30 00:16:41 +02:00
6b85f11871 feat: settings page with integrations, sync control and user management 2026-08-30 00:00:44 +02:00
7a58f9e5a0 fix: hide barcode line on add-page confirm view 2026-08-29 23:40:26 +02:00
bc62019162 feat: add page with discogs text search into confirm flow 2026-08-29 23:34:53 +02:00
71423c7ab7 fix: clear re-match selection when searching again 2026-08-29 23:27:54 +02:00
e912d9e485 feat: item detail with rip override, re-match and remove 2026-08-29 23:19:48 +02:00
b87e6abee0 feat: library grid with rip/format filters, search and artist index 2026-08-29 23:10:20 +02:00
b9ba4f0ca6 fix: dispatch ADD_START, correlate preview/lookup responses with the current attempt 2026-08-29 22:54:00 +02:00
c76d936f1e feat: scan flow page with candidates, confirm view and error guidance 2026-08-29 22:37:01 +02:00
890e7da3a7 feat: pure scan-flow state machine 2026-08-29 22:25:53 +02:00
70b7fdbe1b feat: barcode scanner hook (BarcodeDetector + zxing fallback) and camera component 2026-08-29 22:11:01 +02:00
bda43ce237 feat: router, tab shell, setup and login pages 2026-08-29 19:50:10 +02:00
fdf200a960 feat: typed api client and auth context with setup gate 2026-08-29 19:34:02 +02:00
63e8c5f054 feat: react/vite/tailwind web scaffold with jsdom tests 2026-08-29 19:26:10 +02:00
26609d8b62 docs: frontend implementation plan (12 tasks) 2026-08-29 19:16:27 +02:00
45 changed files with 9651 additions and 10 deletions

View File

@@ -12,7 +12,8 @@ RUN npm ci
COPY tsconfig.json vitest.config.ts ./
COPY server ./server
RUN npm run build:server && npm test
COPY web ./web
RUN npm run build:server && npm run build:web && npm test
RUN npm prune --omit=dev
# ---- runtime stage ----
@@ -23,6 +24,7 @@ WORKDIR /app
COPY package.json package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/web/dist ./web/dist
ENV PORT=3000
ENV DATA_DIR=/data

View File

@@ -22,8 +22,9 @@ token and (optionally) your Subsonic server details in Settings.
```bash
npm install
npm test # vitest
npm run dev:server
npm test # vitest (server + web)
npm run dev # API on :3000 + Vite dev server on :5173 (proxied)
npm run build # server + web production build
```
Spec: `docs/superpowers/specs/2026-08-29-record-shop-design.md`

File diff suppressed because it is too large Load Diff

2305
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,29 +3,48 @@
"version": "0.1.0",
"private": true,
"type": "module",
"engines": { "node": ">=20" },
"engines": {
"node": ">=20"
},
"scripts": {
"dev:server": "tsx watch server/src/index.ts",
"dev": "npm run dev:server",
"dev:web": "vite --config web/vite.config.ts",
"dev": "concurrently -k \"npm:dev:server\" \"npm:dev:web\"",
"test": "vitest run",
"test:watch": "vitest",
"build:server": "tsc -p server/tsconfig.build.json",
"build": "npm run build:server",
"build:web": "vite build --config web/vite.config.ts",
"build": "npm run build:server && npm run build:web",
"start": "node server/dist/index.js",
"typecheck": "tsc -p server/tsconfig.json --noEmit"
"typecheck": "tsc -p server/tsconfig.json --noEmit && tsc -p web --noEmit"
},
"dependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/static": "^8.0.0",
"@zxing/library": "^0.23.0",
"argon2": "^0.41.1",
"better-sqlite3": "^13.0.3",
"fastify": "^5.0.0"
"fastify": "^5.0.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.6",
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.5.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^5.2.0",
"concurrently": "^10.0.5",
"jsdom": "^30.0.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.16.0",
"typescript": "^5.5.4",
"vite": "^7.3.6",
"vitest": "^3.0.0"
}
}

View File

@@ -2,6 +2,22 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['server/test/**/*.test.ts'],
projects: [
{
test: {
name: 'server',
include: ['server/test/**/*.test.ts'],
environment: 'node',
},
},
{
test: {
name: 'web',
include: ['web/test/**/*.test.{ts,tsx}'],
environment: 'jsdom',
setupFiles: ['web/test/setup.ts'],
},
},
],
},
})

16
web/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0a0a0a" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon.svg" />
<title>record-shop</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7
web/public/icon.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#0a0a0a"/>
<circle cx="256" cy="256" r="160" fill="#171717" stroke="#34d399" stroke-width="16"/>
<circle cx="256" cy="256" r="96" fill="none" stroke="#262626" stroke-width="8"/>
<circle cx="256" cy="256" r="40" fill="#34d399"/>
<circle cx="256" cy="256" r="12" fill="#0a0a0a"/>
</svg>

After

Width:  |  Height:  |  Size: 403 B

View File

@@ -0,0 +1,13 @@
{
"name": "record-shop",
"short_name": "record-shop",
"description": "Track your physical music collection",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#0a0a0a",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "maskable" }
]
}

11
web/public/sw.js Normal file
View File

@@ -0,0 +1,11 @@
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
self.addEventListener('fetch', () => {
// no-op: presence of a fetch handler enables PWA install
})

48
web/src/App.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import type { ReactNode } from 'react'
import { AuthProvider, useAuth } from './auth'
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'
function Gate({ children }: { children: ReactNode }) {
const { status } = useAuth()
if (status === 'loading') {
return <div className="min-h-dvh bg-neutral-950" aria-busy="true" />
}
if (status === 'setup') return <Navigate to="/setup" replace />
if (status === 'unauthenticated') return <Navigate to="/login" replace />
return <>{children}</>
}
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/setup" element={<SetupPage />} />
<Route path="/login" element={<LoginPage />} />
<Route
element={
<Gate>
<Shell />
</Gate>
}
>
<Route path="/library" element={<LibraryPage />} />
<Route path="/item/:id" element={<ItemPage />} />
<Route path="/scan" element={<ScanPage />} />
<Route path="/add" element={<AddPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/library" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
)
}

100
web/src/api.ts Normal file
View File

@@ -0,0 +1,100 @@
import type {
Candidate,
CollectionResponse,
DigitalAlbum,
Item,
ReleasePreview,
SettingsView,
SyncState,
User,
} from './types'
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
public detail?: string
) {
super(detail ? `${code}: ${detail}` : code)
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
headers: { Accept: 'application/json' },
...init,
})
if (!res.ok) {
let code = 'unknown_error'
let detail: string | undefined
try {
const body = (await res.json()) as { error?: string; detail?: string }
code = body.error ?? code
detail = body.detail
} catch {
// non-JSON error body
}
throw new ApiError(res.status, code, detail)
}
return (await res.json()) as T
}
function post<T>(path: string, payload?: unknown): Promise<T> {
return request<T>(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload === undefined ? undefined : JSON.stringify(payload),
})
}
export const api = {
setupStatus: () => request<{ needed: boolean }>('/api/setup'),
setup: (username: string, password: string) => post<{ user: User }>('/api/setup', { username, password }),
login: (username: string, password: string) => post<{ user: User }>('/api/login', { username, password }),
logout: () => post<{ ok: boolean }>('/api/logout'),
me: () => request<{ user: User }>('/api/me'),
listUsers: () => request<{ users: User[] }>('/api/users'),
createUser: (username: string, password: string) => post<User>('/api/users', { username, password }),
deleteUser: (id: number) => request<{ ok: boolean }>(`/api/users/${id}`, { method: 'DELETE' }),
getSettings: () => request<SettingsView>('/api/settings'),
putSettings: (payload: Partial<Record<'discogsToken' | 'subsonicUrl' | 'subsonicUsername' | 'subsonicPassword', string>>) =>
request<SettingsView>('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
lookupBarcode: (code: string) => request<{ candidates: Candidate[] }>(`/api/lookup/barcode/${encodeURIComponent(code)}`),
lookupSearch: (q: string, format?: string) =>
request<{ candidates: Candidate[] }>(
`/api/lookup/search?q=${encodeURIComponent(q)}${format ? `&format=${encodeURIComponent(format)}` : ''}`
),
getReleasePreview: (id: number) => request<ReleasePreview>(`/api/lookup/release/${id}`),
listCollection: (params: { format?: string; ripped?: string; q?: string } = {}) => {
const usp = new URLSearchParams()
if (params.format) usp.set('format', params.format)
if (params.ripped) usp.set('ripped', params.ripped)
if (params.q) usp.set('q', params.q)
const qs = usp.toString()
return request<CollectionResponse>(`/api/collection${qs ? `?${qs}` : ''}`)
},
addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) =>
post<Item>('/api/collection', body),
getItem: (id: number) => request<Item>(`/api/collection/${id}`),
deleteItem: (id: number) => request<{ ok: boolean }>(`/api/collection/${id}`, { method: 'DELETE' }),
setRip: (id: number, ripped: boolean | null) =>
request<Item>(`/api/collection/${id}/rip`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ripped }),
}),
setMatch: (id: number, albumId: number | null) =>
post<Item>(`/api/collection/${id}/match`, { albumId }),
syncStatus: () => request<SyncState>('/api/library/sync'),
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`),
}

70
web/src/auth.tsx Normal file
View File

@@ -0,0 +1,70 @@
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react'
import { api } from './api'
import type { User } from './types'
export type AuthStatus = 'loading' | 'setup' | 'unauthenticated' | 'authenticated'
interface AuthContextValue {
status: AuthStatus
user: User | null
setupNeeded: boolean
refresh: () => Promise<void>
onSetupComplete: (user: User) => void
onLogin: (user: User) => void
onLogout: () => void
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<AuthStatus>('loading')
const [user, setUser] = useState<User | null>(null)
const [setupNeeded, setSetupNeeded] = useState(false)
const refresh = useCallback(async () => {
try {
const { needed } = await api.setupStatus()
if (needed) {
setSetupNeeded(true)
setStatus('setup')
return
}
setSetupNeeded(false)
const { user: me } = await api.me()
setUser(me)
setStatus('authenticated')
} catch {
setStatus('unauthenticated')
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const onSetupComplete = useCallback((u: User) => {
setSetupNeeded(false)
setUser(u)
setStatus('authenticated')
}, [])
const onLogin = useCallback((u: User) => {
setUser(u)
setStatus('authenticated')
}, [])
const onLogout = useCallback(() => {
setUser(null)
setStatus('unauthenticated')
}, [])
return (
<AuthContext.Provider value={{ status, user, setupNeeded, refresh, onSetupComplete, onLogin, onLogout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth outside AuthProvider')
return ctx
}

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

@@ -0,0 +1,30 @@
import { Link } from 'react-router-dom'
import type { Item } from '../types.js'
import Cover from './Cover.js'
export default function CoverGrid({ items }: { items: Item[] }) {
return (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
{items.map((item) => (
<Link
key={item.id}
to={`/item/${item.id}`}
aria-label={`${item.artist}${item.title}`}
className="group"
>
<div className="relative">
<Cover src={item.artworkUrl} alt="" className="aspect-square w-full" />
<span
aria-label={item.ripStatus === 'ripped' ? 'ripped' : 'not ripped'}
className={`absolute right-1.5 top-1.5 size-3 rounded-full ring-2 ring-neutral-950 ${
item.ripStatus === 'ripped' ? 'bg-emerald-400' : 'bg-amber-500'
}`}
/>
</div>
<p className="mt-1 truncate text-xs font-medium">{item.title}</p>
<p className="truncate text-xs text-neutral-500">{item.artist}</p>
</Link>
))}
</div>
)
}

View File

@@ -0,0 +1,39 @@
import { useRef } from 'react'
import { useBarcodeScanner } from '../hooks/useBarcodeScanner.js'
export default function Scanner({
enabled,
onDetect,
}: {
enabled: boolean
onDetect: (code: string) => void
}) {
const videoRef = useRef<HTMLVideoElement | null>(null)
const { status } = useBarcodeScanner(videoRef, onDetect, enabled)
return (
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-2xl bg-black">
<video ref={videoRef} className="h-full w-full object-cover" playsInline muted />
<div
aria-hidden
className="pointer-events-none absolute inset-8 rounded-2xl border-2 border-emerald-400/80"
/>
{status === 'starting' && (
<p className="absolute inset-x-0 top-1/2 text-center text-sm text-neutral-300">Starting camera</p>
)}
{status === 'denied' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-6 text-center">
<p className="font-medium">Camera permission denied</p>
<p className="text-sm text-neutral-400">
Allow camera access in your browser settings, then reload this page.
</p>
</div>
)}
{status === 'error' && (
<p className="absolute inset-x-0 top-1/2 text-center text-sm text-red-400">
Camera could not start.
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react'
export type ScannerStatus = 'idle' | 'starting' | 'ready' | 'denied' | 'error'
export const BARCODE_FORMATS = ['ean_13', 'upc_a', 'ean_8'] as const
interface DetectedCode {
rawValue: string
format: string
}
interface BarcodeDetectorLike {
detect(source: HTMLVideoElement): Promise<DetectedCode[]>
}
type BarcodeDetectorCtor = new (options: { formats: string[] }) => BarcodeDetectorLike
export function getBarcodeDetectorCtor(): BarcodeDetectorCtor | null {
return (window as unknown as { BarcodeDetector?: BarcodeDetectorCtor }).BarcodeDetector ?? null
}
/** Pure dedup rule: suppress a repeat of the same code within cooldownMs. */
export function shouldEmit(
last: { code: string; at: number } | null,
code: string,
now: number,
cooldownMs: number
): boolean {
if (!last) return true
if (last.code !== code) return true
return now - last.at >= cooldownMs
}
export function useBarcodeScanner(
videoRef: React.RefObject<HTMLVideoElement | null>,
onDetect: (code: string) => void,
enabled: boolean
): { status: ScannerStatus; stop: () => void } {
const [status, setStatus] = useState<ScannerStatus>('idle')
const onDetectRef = useRef(onDetect)
onDetectRef.current = onDetect
useEffect(() => {
if (!enabled) {
setStatus('idle')
return
}
let stopped = false
let stream: MediaStream | null = null
let rafId = 0
let zxingStop: (() => void) | null = null
let last: { code: string; at: number } | null = null
const emit = (code: string) => {
const now = Date.now()
if (!shouldEmit(last, code, now, 1500)) return
last = { code, at: now }
onDetectRef.current(code)
}
async function start(): Promise<void> {
setStatus('starting')
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
} catch (err) {
setStatus(err instanceof DOMException && err.name === 'NotAllowedError' ? 'denied' : 'error')
return
}
const video = videoRef.current
if (!video || stopped) {
stream.getTracks().forEach((t) => t.stop())
return
}
video.srcObject = stream
await video.play().catch(() => {})
const Ctor = getBarcodeDetectorCtor()
if (Ctor) {
const detector = new Ctor({ formats: [...BARCODE_FORMATS] })
const tick = async (): Promise<void> => {
if (stopped) return
try {
const codes = await detector.detect(video)
const first = codes[0]
if (first) emit(first.rawValue)
} catch {
// undecodable frame — skip
}
rafId = requestAnimationFrame(() => void tick())
}
void tick()
} else {
const { BrowserMultiFormatReader, BarcodeFormat, DecodeHintType } = await import('@zxing/library')
if (stopped) return
const hints = new Map()
hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.EAN_13, BarcodeFormat.UPC_A, BarcodeFormat.EAN_8])
const reader = new BrowserMultiFormatReader(hints)
// 0.23.0's decodeFromStream returns Promise<void> and only resolves once
// its decode loop is stopped — start it without awaiting and break the
// loop on cleanup via stopContinuousDecode().
zxingStop = () => reader.stopContinuousDecode()
void reader
.decodeFromStream(stream, video, (result) => {
if (result) emit(result.getText())
})
.catch(() => {})
}
if (!stopped) setStatus('ready')
}
void start()
return () => {
stopped = true
cancelAnimationFrame(rafId)
zxingStop?.()
stream?.getTracks().forEach((t) => t.stop())
setStatus('idle')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, videoRef])
return { status, stop: () => undefined }
}

14
web/src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
if ('serviceWorker' in navigator && import.meta.env.PROD) {
void navigator.serviceWorker.register('/sw.js')
}

206
web/src/pages/AddPage.tsx Normal file
View File

@@ -0,0 +1,206 @@
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>
)
}

246
web/src/pages/ItemPage.tsx Normal file
View File

@@ -0,0 +1,246 @@
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<Item | null>(null)
const [error, setError] = useState(false)
const [matching, setMatching] = useState(false)
const [albumQuery, setAlbumQuery] = useState('')
const [albums, setAlbums] = useState<DigitalAlbum[] | null>(null)
const [pickedAlbum, setPickedAlbum] = useState<number | null>(null)
const [confirmRemove, setConfirmRemove] = useState(false)
const [mutationError, setMutationError] = useState<string | null>(null)
useEffect(() => {
setMutationError(null)
void api
.getItem(Number(id))
.then(setItem)
.catch(() => setError(true))
}, [id])
function searchAlbums() {
void api
.searchAlbums(albumQuery)
.then((res) => {
setAlbums(res.albums)
setPickedAlbum(null)
})
.catch(() => setAlbums([]))
}
function applyMatch(albumId: number | null) {
if (!item) return
void api
.setMatch(item.id, albumId)
.then((updated) => {
setItem(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
}
function remove() {
if (!item) return
void api.deleteItem(item.id).then(() => navigate('/library')).catch(() =>
setMutationError("That didn't work — check your connection and try again.")
)
}
if (error) return <p className="py-8 text-center text-sm text-red-400">Item not found.</p>
if (!item) return <p className="py-8 text-center text-sm text-neutral-400">Loading</p>
return (
<div className="space-y-4">
<div className="flex gap-4">
<Cover src={item.artworkUrl} alt="" className="size-32 shrink-0" />
<div className="min-w-0">
<h2 className="text-lg font-semibold leading-tight">{item.title}</h2>
<p className="text-neutral-400">{item.artist}</p>
<p className="text-xs text-neutral-500">
{[item.year, item.formats.join(', '), item.labels.join(', '), item.catno, item.country]
.filter(Boolean)
.join(' · ')}
</p>
{item.genres.length > 0 && <p className="mt-1 text-xs text-neutral-500">{item.genres.join(', ')}</p>}
<a
href={`https://www.discogs.com/release/${item.discogsReleaseId}`}
target="_blank"
rel="noreferrer"
className="mt-1 inline-block text-xs text-emerald-400"
>
View on Discogs
</a>
</div>
</div>
{item.ripStatus === 'ripped' ? (
<p className="rounded-xl bg-emerald-500/10 px-4 py-3 text-sm text-emerald-400">
In your digital collection
{item.ripOverride !== null && ' (manually set)'}
</p>
) : (
<p className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-400">
Not ripped yet
{item.ripOverride !== null && ' (manually set)'}
</p>
)}
{mutationError && <p className="text-sm text-red-400">{mutationError}</p>}
<div className="flex flex-wrap gap-2">
{item.ripStatus === 'ripped' ? (
<button
type="button"
onClick={() =>
void api
.setRip(item.id, false)
.then((updated) => {
setItem(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Mark not ripped
</button>
) : (
<button
type="button"
onClick={() =>
void api
.setRip(item.id, true)
.then((updated) => {
setItem(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Mark ripped
</button>
)}
{item.ripOverride !== null && (
<button
type="button"
onClick={() =>
void api
.setRip(item.id, null)
.then((updated) => {
setItem(updated)
setMutationError(null)
})
.catch(() => setMutationError("That didn't work — check your connection and try again."))
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Reset to auto
</button>
)}
</div>
<section className="rounded-xl border border-neutral-800 bg-neutral-900 p-3">
<button
type="button"
onClick={() => setMatching((m) => !m)}
className="text-sm font-medium text-neutral-200"
aria-expanded={matching}
>
Re-match
</button>
{matching && (
<div className="mt-3 space-y-2">
<div className="flex gap-2">
<input
value={albumQuery}
onChange={(e) => 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"
/>
<button
type="button"
onClick={searchAlbums}
className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Search
</button>
</div>
{albums && albums.length === 0 && <p className="text-sm text-neutral-500">No matches in your library.</p>}
{albums && albums.length > 0 && (
<div className="space-y-1.5">
{albums.map((a) => (
<label key={a.id} className="flex items-center gap-2 text-sm">
<input
type="radio"
name="album"
checked={pickedAlbum === a.id}
onChange={() => setPickedAlbum(a.id)}
/>
{a.artist} {a.title}
</label>
))}
</div>
)}
<div className="flex gap-2">
<button
type="button"
disabled={pickedAlbum === null}
onClick={() => applyMatch(pickedAlbum)}
className="rounded-lg bg-emerald-500 px-3 py-1.5 text-sm font-medium text-neutral-950 disabled:opacity-50"
>
Link
</button>
<button
type="button"
onClick={() => applyMatch(null)}
className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Unlink
</button>
</div>
</div>
)}
</section>
{item.tracklist.length > 0 && (
<details className="rounded-xl border border-neutral-800 bg-neutral-900 p-3" open>
<summary className="cursor-pointer text-sm text-neutral-300">Tracklist</summary>
<ol className="mt-2 space-y-1 text-sm text-neutral-400">
{item.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>
)}
{item.barcodes.length > 0 && (
<p className="text-xs text-neutral-500">Barcodes: {item.barcodes.join(', ')}</p>
)}
<div className="flex justify-between border-t border-neutral-800 pt-4">
<Link to="/library" className="text-sm text-neutral-400">
Back
</Link>
{confirmRemove ? (
<button type="button" onClick={remove} className="text-sm font-medium text-red-400">
Confirm remove
</button>
) : (
<button type="button" onClick={() => setConfirmRemove(true)} className="text-sm text-red-400">
Remove
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api.js'
import type { CollectionResponse } from '../types.js'
import CoverGrid from '../components/CoverGrid.js'
const FORMATS = ['All', 'Vinyl', 'CD', 'Cassette'] as const
const RIP = ['All', 'Ripped', 'Not ripped'] as const
export default function LibraryPage() {
const [format, setFormat] = useState<(typeof FORMATS)[number]>('All')
const [ripped, setRipped] = useState<(typeof RIP)[number]>('All')
const [q, setQ] = useState('')
const [data, setData] = useState<CollectionResponse | null>(null)
const [error, setError] = useState(false)
const seq = useRef(0)
useEffect(() => {
const mine = ++seq.current
const params = {
...(format !== 'All' ? { format } : {}),
...(ripped !== 'All' ? { ripped: ripped === 'Ripped' ? 'ripped' : 'not_ripped' } : {}),
...(q.trim() ? { q: q.trim() } : {}),
}
void api
.listCollection(params)
.then((res) => {
if (seq.current === mine) {
setData(res)
setError(false)
}
})
.catch(() => {
if (seq.current === mine) setError(true)
})
}, [format, ripped, q])
const artists = useMemo(() => {
const set = new Set<string>()
for (const item of data?.items ?? []) set.add(item.artist)
return [...set].sort((a, b) => a.localeCompare(b))
}, [data])
return (
<div className="space-y-4">
<input
type="search"
placeholder="Search title or artist"
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-full rounded-xl border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
/>
<div className="flex flex-wrap gap-2">
{FORMATS.map((f) => (
<button
key={f}
type="button"
onClick={() => setFormat(f)}
className={`rounded-full px-3 py-1 text-xs font-medium ${
format === f ? 'bg-emerald-500 text-neutral-950' : 'border border-neutral-700 text-neutral-300'
}`}
>
{f}
</button>
))}
{RIP.map((r) => (
<button
key={r}
type="button"
onClick={() => setRipped(r)}
className={`rounded-full px-3 py-1 text-xs font-medium ${
ripped === r ? 'bg-emerald-500 text-neutral-950' : 'border border-neutral-700 text-neutral-300'
}`}
>
{r}
</button>
))}
</div>
{error && <p className="text-sm text-red-400">Could not load your collection.</p>}
{data && (
<>
<p className="text-xs text-neutral-500">
{data.counts.total} in collection · {data.counts.ripped} ripped · {data.counts.notRipped} not ripped
</p>
{data.items.length === 0 ? (
<div className="py-12 text-center">
<p className="text-neutral-400">Nothing here yet.</p>
<Link to="/add" className="mt-2 inline-block text-sm text-emerald-400">
Add your first record
</Link>
</div>
) : (
<CoverGrid items={data.items} />
)}
{!q && artists.length > 1 && (
<details className="rounded-xl border border-neutral-800 bg-neutral-900 p-3">
<summary className="cursor-pointer text-sm text-neutral-300">Artists</summary>
<div className="mt-2 flex flex-wrap gap-1.5">
{artists.map((artist) => (
<button
key={artist}
type="button"
onClick={() => setQ(artist)}
className="rounded-full border border-neutral-700 px-2.5 py-0.5 text-xs text-neutral-300"
>
{artist}
</button>
))}
</div>
</details>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,71 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError } from '../api'
import { useAuth } from '../auth'
export default function LoginPage() {
const { onLogin } = useAuth()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function submit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
const { user } = await api.login(username, password)
onLogin(user)
navigate('/library', { replace: true })
} catch (err) {
setError(err instanceof ApiError && err.status === 401 ? 'Wrong username or password' : 'Something went wrong')
} finally {
setBusy(false)
}
}
return (
<div className="min-h-dvh bg-neutral-950 text-neutral-100 flex items-center justify-center p-4">
<form onSubmit={submit} className="w-full max-w-sm space-y-4">
<h1 className="text-2xl font-semibold">Sign in</h1>
<div>
<label htmlFor="username" className="mb-1 block text-sm">
Username
</label>
<input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2"
autoComplete="username"
required
/>
</div>
<div>
<label htmlFor="password" className="mb-1 block text-sm">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2"
autoComplete="current-password"
required
/>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full rounded-lg bg-emerald-500 py-2 font-medium text-neutral-950 disabled:opacity-50"
>
Sign in
</button>
</form>
</div>
)
}

213
web/src/pages/ScanPage.tsx Normal file
View File

@@ -0,0 +1,213 @@
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_auth') return 'discogs_auth'
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, source: 'lookup' })
}
})()
}, [])
// 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, code, matchAlbumId } = state
dispatch({ type: 'ADD_START' })
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 === 'discogs_auth' && (
<div className="space-y-3 py-8 text-center">
<p className="text-lg font-medium">Discogs rejected your token</p>
<p className="text-sm text-neutral-400">Check your Discogs token in Settings.</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 === '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,284 @@
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api.js'
import { useAuth } from '../auth.js'
import type { SettingsView, SyncState, User } from '../types.js'
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="space-y-3 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">{title}</h2>
{children}
</section>
)
}
const inputCls = 'w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm'
export default function SettingsPage() {
const { user, onLogout } = useAuth()
const navigate = useNavigate()
const [view, setView] = useState<SettingsView | null>(null)
const [discogsToken, setDiscogsToken] = useState('')
const [subsonicUrl, setSubsonicUrl] = useState('')
const [subsonicUsername, setSubsonicUsername] = useState('')
const [subsonicPassword, setSubsonicPassword] = useState('')
const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null)
const [sync, setSync] = useState<SyncState | null>(null)
const [users, setUsers] = useState<User[] | null>(null)
const [newUsername, setNewUsername] = useState('')
const [newPassword, setNewPassword] = useState('')
const stopPollRef = useRef<(() => void) | null>(null)
const refreshSettings = useCallback(() => {
void api
.getSettings()
.then((v) => {
setView(v)
setSubsonicUrl(v.subsonicUrl ?? '')
setSubsonicUsername(v.subsonicUsername ?? '')
})
.catch(() => {})
}, [])
useEffect(() => {
refreshSettings()
}, [refreshSettings])
const pollSync = useCallback(() => {
let alive = true
const tick = (): void => {
void api
.syncStatus()
.then((s) => {
if (!alive) return s
setSync(s)
return s
})
.then((s) => {
if (alive && s?.status === 'running') setTimeout(tick, 2000)
})
.catch(() => {})
}
tick()
return () => {
alive = false
}
}, [])
useEffect(() => {
stopPollRef.current = pollSync()
return () => stopPollRef.current?.()
}, [pollSync])
useEffect(() => {
if (user?.isAdmin) {
void api
.listUsers()
.then((res) => setUsers(res.users))
.catch(() => {})
}
}, [user])
function flash(kind: 'ok' | 'error', text: string) {
setMessage({ kind, text })
setTimeout(() => setMessage(null), 4000)
}
function saveDiscogs(e: FormEvent) {
e.preventDefault()
void api
.putSettings({ discogsToken: discogsToken })
.then((v) => {
setView(v)
setDiscogsToken('')
flash('ok', 'Token saved')
})
.catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Save failed'))
}
function saveSubsonic(e: FormEvent) {
e.preventDefault()
const payload: Parameters<typeof api.putSettings>[0] = { subsonicUrl, subsonicUsername }
if (subsonicPassword !== '') payload.subsonicPassword = subsonicPassword
void api
.putSettings(payload)
.then((v) => {
setView(v)
setSubsonicPassword('')
flash('ok', 'Music server saved — syncing library')
})
.catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Save failed'))
}
function addUser(e: FormEvent) {
e.preventDefault()
void api
.createUser(newUsername, newPassword)
.then((created) => {
setUsers((u) => [...(u ?? []), created])
setNewUsername('')
setNewPassword('')
})
.catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Could not add user'))
}
function removeUser(id: number, username: string) {
void api.deleteUser(id).then(() => setUsers((u) => (u ?? []).filter((x) => x.id !== id || x.username !== username)))
}
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Settings</h1>
{message && (
<p className={`rounded-xl px-4 py-3 text-sm ${message.kind === 'ok' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>
{message.text}
</p>
)}
<Section title="Account">
<p className="text-sm">
{user?.username} {user?.isAdmin && <span className="text-neutral-500">(admin)</span>}
</p>
<button
type="button"
onClick={() =>
void api.logout().then(() => {
onLogout()
navigate('/login')
})
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Log out
</button>
</Section>
<Section title="Discogs">
<p className="text-xs text-neutral-500">
Personal access token from discogs.com Settings Developers.{' '}
{view?.hasDiscogsToken && `Current: ${view.discogsTokenMasked}`}
</p>
<form onSubmit={saveDiscogs} className="space-y-2">
<label className="block text-sm">
Discogs token
<input
value={discogsToken}
onChange={(e) => setDiscogsToken(e.target.value)}
className={inputCls}
autoComplete="off"
placeholder={view?.hasDiscogsToken ? 'Paste token (saving empty removes it)' : 'Paste token'}
/>
</label>
<button type="submit" className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-medium text-neutral-950">
Save Discogs
</button>
</form>
</Section>
<Section title="Music server (Subsonic)">
<form onSubmit={saveSubsonic} className="space-y-2">
<label className="block text-sm">
Subsonic URL
<input value={subsonicUrl} onChange={(e) => setSubsonicUrl(e.target.value)} className={inputCls} placeholder="http://navidrome.local" />
</label>
<label className="block text-sm">
Subsonic username
<input value={subsonicUsername} onChange={(e) => setSubsonicUsername(e.target.value)} className={inputCls} autoComplete="off" />
</label>
<label className="block text-sm">
Subsonic password
<input
type="password"
value={subsonicPassword}
onChange={(e) => setSubsonicPassword(e.target.value)}
className={inputCls}
autoComplete="new-password"
placeholder={view?.hasSubsonicPassword ? 'Saved — leave blank to keep' : ''}
/>
</label>
<button type="submit" className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-medium text-neutral-950">
Save Subsonic
</button>
</form>
</Section>
<Section title="Library sync">
{sync && (
<p className="text-sm text-neutral-300">
{sync.status === 'running' && 'Syncing…'}
{sync.status === 'done' && `${sync.albums} albums synced`}
{sync.status === 'error' && <span className="text-red-400">Sync failed: {sync.error}</span>}
{sync.status === 'idle' && 'Not synced yet'}
{sync.lastSyncedAt && (
<span className="text-neutral-500"> · last {new Date(sync.lastSyncedAt).toLocaleString()}</span>
)}
</p>
)}
<button
type="button"
onClick={() =>
void api
.startSync()
.then((s) => {
setSync(s)
stopPollRef.current?.()
stopPollRef.current = pollSync()
})
.catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Sync failed'))
}
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Sync now
</button>
</Section>
{user?.isAdmin && (
<Section title="Users">
<ul className="space-y-1.5 text-sm">
{(users ?? []).map((u) => (
<li key={u.id} className="flex items-center justify-between">
<span>
{u.username} {u.isAdmin && <span className="text-neutral-500">(admin)</span>}
</span>
{!u.isAdmin && (
<button
type="button"
onClick={() => removeUser(u.id, u.username)}
className="text-xs text-red-400"
aria-label={`remove ${u.username}`}
>
remove
</button>
)}
</li>
))}
</ul>
<form onSubmit={addUser} className="space-y-2 border-t border-neutral-800 pt-3">
<label className="block text-sm">
New username
<input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className={inputCls} autoComplete="off" />
</label>
<label className="block text-sm">
New password
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={inputCls}
autoComplete="new-password"
/>
</label>
<button type="submit" className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-medium text-neutral-950">
Add user
</button>
</form>
</Section>
)}
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError } from '../api'
import { useAuth } from '../auth'
export default function SetupPage() {
const { onSetupComplete } = useAuth()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function submit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
const { user } = await api.setup(username, password)
onSetupComplete(user)
navigate('/library', { replace: true })
} catch (err) {
setError(err instanceof ApiError ? err.detail ?? err.code : 'Something went wrong')
} finally {
setBusy(false)
}
}
return (
<div className="min-h-dvh bg-neutral-950 text-neutral-100 flex items-center justify-center p-4">
<form onSubmit={submit} className="w-full max-w-sm space-y-4">
<h1 className="text-2xl font-semibold">Welcome to record-shop</h1>
<p className="text-sm text-neutral-400">Create the admin account to get started.</p>
<div>
<label htmlFor="username" className="mb-1 block text-sm">
Username
</label>
<input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2"
autoComplete="username"
required
minLength={3}
/>
</div>
<div>
<label htmlFor="password" className="mb-1 block text-sm">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2"
autoComplete="new-password"
required
minLength={8}
/>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full rounded-lg bg-emerald-500 py-2 font-medium text-neutral-950 disabled:opacity-50"
>
Create admin account
</button>
</form>
</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>
)
}

81
web/src/scan/reducer.ts Normal file
View File

@@ -0,0 +1,81 @@
import type { Candidate, Item, ReleasePreview } from '../types.js'
export type ScanErrorKind = 'not_found' | 'no_discogs_token' | 'discogs_auth' | 'rate_limited' | 'server'
export type ScanState =
| { phase: 'scan' }
| { phase: 'looking'; code: string }
| { phase: 'candidates'; code: string; candidates: Candidate[] }
| {
phase: 'confirm'
code: string | null
candidate: Candidate
preview: ReleasePreview | null
matchAlbumId: number | null
adding: boolean
addError: string | null
}
| { phase: 'added'; item: Item }
| { phase: 'error'; kind: ScanErrorKind; code: string | null }
export const INITIAL_SCAN_STATE: ScanState = { phase: 'scan' }
export type ScanAction =
| { type: 'DETECT'; code: string }
| { type: 'CANDIDATES'; code: string; candidates: Candidate[] }
| { type: 'NOT_FOUND'; code: string }
| { type: 'ERROR'; kind: ScanErrorKind; code: string | null; source: 'lookup' | 'preview'; candidateId?: number }
| { type: 'SELECT'; candidate: Candidate }
| { type: 'PREVIEW'; preview: ReleasePreview; candidateId: number }
| { type: 'SET_MATCH'; albumId: number }
| { type: 'CLEAR_MATCH' }
| { type: 'ADD_START' }
| { type: 'ADDED'; item: Item }
| { type: 'ADD_ERROR'; message: string }
| { type: 'RESET' }
export function scanReducer(state: ScanState, action: ScanAction): ScanState {
switch (action.type) {
case 'DETECT':
return state.phase === 'scan' ? { phase: 'looking', code: action.code } : state
case 'CANDIDATES':
return state.phase === 'looking' ? { phase: 'candidates', code: action.code, candidates: action.candidates } : state
case 'NOT_FOUND':
return state.phase === 'looking' ? { phase: 'error', kind: 'not_found', code: action.code } : state
case 'ERROR':
if (action.source === 'lookup') {
return state.phase === 'looking' ? { phase: 'error', kind: action.kind, code: action.code } : state
}
return state.phase === 'confirm' && state.candidate.id === action.candidateId
? { phase: 'error', kind: action.kind, code: action.code }
: state
case 'SELECT':
return state.phase === 'candidates'
? {
phase: 'confirm',
code: state.code,
candidate: action.candidate,
preview: null,
matchAlbumId: null,
adding: false,
addError: null,
}
: state
case 'PREVIEW':
return state.phase === 'confirm' && state.candidate.id === action.candidateId
? { ...state, preview: action.preview }
: state
case 'SET_MATCH':
return state.phase === 'confirm' ? { ...state, matchAlbumId: action.albumId } : state
case 'CLEAR_MATCH':
return state.phase === 'confirm' ? { ...state, matchAlbumId: null } : state
case 'ADD_START':
return state.phase === 'confirm' && state.preview ? { ...state, adding: true, addError: null } : state
case 'ADDED':
return state.phase === 'confirm' ? { phase: 'added', item: action.item } : state
case 'ADD_ERROR':
return state.phase === 'confirm' ? { ...state, adding: false, addError: action.message } : state
case 'RESET':
return INITIAL_SCAN_STATE
}
}

79
web/src/shell.tsx Normal file
View File

@@ -0,0 +1,79 @@
import { NavLink, Outlet } from 'react-router-dom'
import type { ReactNode } from 'react'
const TABS: { to: string; label: string; icon: ReactNode }[] = [
{
to: '/library',
label: 'Library',
icon: (
<svg viewBox="0 0 24 24" className="size-6" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18M9 21V9" />
</svg>
),
},
{
to: '/scan',
label: 'Scan',
icon: (
<svg viewBox="0 0 24 24" className="size-6" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2M7 12h10" />
</svg>
),
},
{
to: '/add',
label: 'Add',
icon: (
<svg viewBox="0 0 24 24" className="size-6" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<circle cx="12" cy="12" r="9" />
<path d="M12 8v8M8 12h8" />
</svg>
),
},
{
to: '/settings',
label: 'Settings',
icon: (
<svg viewBox="0 0 24 24" className="size-6" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
</svg>
),
},
]
export default function Shell({ title }: { title?: string }) {
return (
<div className="min-h-dvh bg-neutral-950 text-neutral-100">
<header className="sticky top-0 z-10 border-b border-neutral-800 bg-neutral-950/90 backdrop-blur">
<h1 className="mx-auto max-w-3xl px-4 py-3 text-lg font-semibold">{title ?? 'record-shop'}</h1>
</header>
<main className="mx-auto max-w-3xl px-4 pb-24 pt-4">
<Outlet />
</main>
<nav
aria-label="Main"
className="fixed inset-x-0 bottom-0 z-10 border-t border-neutral-800 bg-neutral-950/95 backdrop-blur"
>
<div className="mx-auto flex max-w-3xl">
{TABS.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
role="tab"
className={({ isActive }) =>
`flex flex-1 flex-col items-center gap-1 py-2 text-xs ${
isActive ? 'text-emerald-400' : 'text-neutral-400'
}`
}
>
{tab.icon}
{tab.label}
</NavLink>
))}
</div>
</nav>
</div>
)
}

1
web/src/styles.css Normal file
View File

@@ -0,0 +1 @@
@import 'tailwindcss';

70
web/src/types.ts Normal file
View File

@@ -0,0 +1,70 @@
// Contract types — mirrors the Fastify API (plan 1). Do not rename fields.
export interface User {
id: number
username: string
isAdmin: boolean
}
export interface SettingsView {
hasDiscogsToken: boolean
discogsTokenMasked: string | null
subsonicUrl: string | null
subsonicUsername: string | null
hasSubsonicPassword: boolean
}
export interface Candidate {
id: number
artist: string
title: string
year: number | null
formats: string[]
labels: string[]
country: string | null
catno: string | null
thumbUrl: string | null
}
export interface Release extends Candidate {
genres: string[]
tracklist: { position: string; title: string }[]
coverUrl: string | null
barcodes: string[]
}
export interface ReleasePreview {
release: Release
duplicate: boolean
ripMatch: 'ripped' | 'not_ripped' | 'ambiguous'
matchCandidates: { id: number; title: string; artist: string }[]
}
export interface Item {
id: number
discogsReleaseId: number
title: string
artist: string
year: number | null
formats: string[]
genres: string[]
labels: string[]
tracklist: { position: string; title: string }[]
catno: string | null
country: string | null
artworkUrl: string | null
barcodes: string[]
dateAdded: string
ripOverride: boolean | null
ripStatus: 'ripped' | 'not_ripped'
}
export interface CollectionResponse {
items: Item[]
counts: { total: number; ripped: number; notRipped: number }
}
export interface SyncState {
status: 'idle' | 'running' | 'done' | 'error'
error: string | null
lastSyncedAt: string | null
albums: number
}
export interface DigitalAlbum {
id: number
subsonicId: string
title: string
artist: string
}

92
web/test/add.test.tsx Normal file
View File

@@ -0,0 +1,92 @@
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 AddPage from '../src/pages/AddPage.js'
import { api, ApiError } from '../src/api.js'
import type { Candidate, ReleasePreview } from '../src/types.js'
vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/api.js')>()
return { ...actual, api: { ...actual.api, lookupSearch: vi.fn(), getReleasePreview: vi.fn(), addToCollection: vi.fn() } }
})
const candidate: Candidate = {
id: 1001,
artist: 'The Cinematic Orchestra',
title: 'Motion',
year: 1999,
formats: ['Vinyl'],
labels: ['Ninja Tune'],
country: 'UK',
catno: 'ZEN012',
thumbUrl: null,
}
const preview: ReleasePreview = {
release: { ...candidate, genres: [], tracklist: [], coverUrl: null, barcodes: [] },
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.lookupSearch).mockReset()
vi.mocked(api.getReleasePreview).mockReset()
vi.mocked(api.addToCollection).mockReset()
})
function renderAdd(initialEntry = '/add') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<AddPage />
</MemoryRouter>
)
}
describe('AddPage', () => {
it('searches discogs and shows candidate cards', async () => {
vi.mocked(api.lookupSearch).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
renderAdd()
await userEvent.type(screen.getByPlaceholderText(/artist and title/i), 'cinematic orchestra motion{Enter}')
await waitFor(() =>
expect(api.lookupSearch).toHaveBeenCalledWith('cinematic orchestra motion', undefined)
)
expect(await screen.findByRole('button', { name: /motion/i })).toBeTruthy()
})
it('passes the format filter and pre-filled query from the URL', async () => {
vi.mocked(api.lookupSearch).mockResolvedValue(jsonOk({ candidates: [] }) as never)
renderAdd('/add?q=5021592210629')
expect((screen.getByPlaceholderText(/artist and title/i) as HTMLInputElement).value).toBe(
'5021592210629'
)
await userEvent.selectOptions(screen.getByLabelText(/format/i), 'Vinyl')
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
await waitFor(() => expect(api.lookupSearch).toHaveBeenCalledWith('5021592210629', 'Vinyl'))
})
it('shows a not-found message', async () => {
vi.mocked(api.lookupSearch).mockRejectedValue(new ApiError(404, 'not_found'))
renderAdd()
await userEvent.type(screen.getByPlaceholderText(/artist and title/i), 'zzz{Enter}')
await waitFor(() => expect(screen.getByText(/nothing found/i)).toBeTruthy())
})
it('selecting a candidate loads the confirm view and adds', async () => {
vi.mocked(api.lookupSearch).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never)
vi.mocked(api.addToCollection).mockResolvedValue(jsonOk({}) as never)
renderAdd()
await userEvent.type(screen.getByPlaceholderText(/artist and title/i), 'motion{Enter}')
await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
await waitFor(() => expect(screen.getByText('Not ripped yet')).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: /add to collection/i }))
await waitFor(() => expect(api.addToCollection).toHaveBeenCalledWith({ releaseId: 1001 }))
})
})

68
web/test/auth.test.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { AuthProvider, useAuth } from '../src/auth'
function Probe() {
const { status, user, setupNeeded } = useAuth()
return (
<div>
<div>status:{status}</div>
{setupNeeded && <div>setup-needed</div>}
{user && <div>user:{user.username}</div>}
</div>
)
}
const fetchMock = vi.fn()
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
function jsonOnce(status: number, body: unknown) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
describe('AuthProvider', () => {
it('reports setupNeeded when the server has no users', async () => {
fetchMock.mockResolvedValueOnce(jsonOnce(200, { needed: true }))
render(
<AuthProvider>
<Probe />
</AuthProvider>
)
expect(screen.getByText('status:loading')).toBeTruthy()
await waitFor(() => expect(screen.getByText('status:setup')).toBeTruthy())
expect(screen.getByText('setup-needed')).toBeTruthy()
expect(fetchMock).toHaveBeenCalledWith('/api/setup', expect.anything())
})
it('exposes the user when /api/me succeeds', async () => {
fetchMock
.mockResolvedValueOnce(jsonOnce(200, { needed: false }))
.mockResolvedValueOnce(jsonOnce(200, { user: { id: 1, username: 'sam', isAdmin: true } }))
render(
<AuthProvider>
<Probe />
</AuthProvider>
)
await waitFor(() => expect(screen.getByText('status:authenticated')).toBeTruthy())
expect(screen.getByText('user:sam')).toBeTruthy()
})
it('reports unauthenticated when /api/me is 401', async () => {
fetchMock
.mockResolvedValueOnce(jsonOnce(200, { needed: false }))
.mockResolvedValueOnce(jsonOnce(401, { error: 'unauthorized' }))
render(
<AuthProvider>
<Probe />
</AuthProvider>
)
await waitFor(() => expect(screen.getByText('status:unauthenticated')).toBeTruthy())
})
})

145
web/test/item.test.tsx Normal file
View File

@@ -0,0 +1,145 @@
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<typeof import('../src/api.js')>()
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(
<MemoryRouter initialEntries={['/item/1']}>
<Routes>
<Route path="/item/:id" element={<ItemPage />} />
<Route path="/library" element={<p>library</p>} />
</Routes>
</MemoryRouter>
)
}
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())
})
it('re-match clears the picked album when searching again', async () => {
vi.mocked(api.searchAlbums)
.mockResolvedValueOnce({
albums: [{ id: 77, subsonicId: 'a1', title: 'Motion (Remaster)', artist: 'The Cinematic Orchestra' }],
} as never)
.mockResolvedValueOnce({
albums: [{ id: 88, subsonicId: 'a2', title: 'Something Else', artist: 'Other Artist' }],
} as never)
renderItem()
await userEvent.click(await screen.findByRole('button', { name: /re-match/i }))
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
await userEvent.click(await screen.findByRole('radio', { name: /motion \(remaster\)/i }))
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
const linkBtn = await screen.findByRole('button', { name: /^link$/i })
expect(linkBtn).toHaveProperty('disabled', true)
})
it('shows Reset to auto for a manual not-ripped override', async () => {
vi.mocked(api.getItem).mockResolvedValue({ ...item, ripOverride: false, ripStatus: 'not_ripped' } as never)
renderItem()
await waitFor(() => expect(screen.getByText(/not ripped yet \(manually set\)/i)).toBeTruthy())
expect(screen.getByRole('button', { name: /reset to auto/i })).toBeTruthy()
expect(screen.getByRole('button', { name: /mark ripped/i })).toBeTruthy()
})
it('shows an error when a rip toggle fails', async () => {
vi.mocked(api.setRip).mockRejectedValue(new TypeError('fetch failed'))
renderItem()
await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i }))
await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy())
})
})

113
web/test/library.test.tsx Normal file
View File

@@ -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 } from 'react-router-dom'
import LibraryPage from '../src/pages/LibraryPage.js'
import type { Item } from '../src/types.js'
vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/api.js')>()
return { ...actual, api: { ...actual.api, listCollection: vi.fn() } }
})
import { api } from '../src/api.js'
function item(overrides: Partial<Item>): Item {
return {
id: 1,
discogsReleaseId: 100,
title: 'Motion',
artist: 'The Cinematic Orchestra',
year: 1999,
formats: ['CD'],
genres: [],
labels: [],
tracklist: [],
catno: null,
country: null,
artworkUrl: null,
barcodes: [],
dateAdded: '2026-08-29',
ripOverride: null,
ripStatus: 'not_ripped',
...overrides,
}
}
const data = {
items: [
item({ id: 1, ripStatus: 'not_ripped' }),
item({ id: 2, title: 'Blue Lines', artist: 'Massive Attack', formats: ['Vinyl'], ripStatus: 'ripped' }),
item({ id: 3, title: 'Mezzanine', artist: 'Massive Attack', formats: ['Cassette'], ripStatus: 'not_ripped' }),
],
counts: { total: 3, ripped: 1, notRipped: 2 },
}
beforeEach(() => {
vi.mocked(api.listCollection).mockReset()
vi.mocked(api.listCollection).mockResolvedValue(data as never)
})
function renderLibrary() {
return render(
<MemoryRouter>
<LibraryPage />
</MemoryRouter>
)
}
describe('LibraryPage', () => {
it('renders covers with artist and rip badges', async () => {
renderLibrary()
await waitFor(() => expect(screen.getAllByRole('link', { name: /motion/i })).toHaveLength(1))
expect(screen.getByRole('link', { name: /blue lines/i })).toBeTruthy()
expect(screen.getByRole('link', { name: /mezzanine/i })).toBeTruthy()
})
it('passes format and rip filters to the api', async () => {
renderLibrary()
await waitFor(() => expect(screen.getByRole('button', { name: /^vinyl$/i })).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: /^vinyl$/i }))
await waitFor(() =>
expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ format: 'Vinyl' }))
)
await userEvent.click(screen.getByRole('button', { name: /^ripped$/i }))
await waitFor(() =>
expect(api.listCollection).toHaveBeenLastCalledWith(
expect.objectContaining({ format: 'Vinyl', ripped: 'ripped' })
)
)
})
it('searches by title/artist via the q filter', async () => {
renderLibrary()
await waitFor(() => expect(screen.getByPlaceholderText(/search/i)).toBeTruthy())
await userEvent.type(screen.getByPlaceholderText(/search/i), 'mezz')
await waitFor(() =>
expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'mezz' }))
)
})
it('shows collection counts', async () => {
renderLibrary()
await waitFor(() => expect(screen.getByText(/3 in collection/i)).toBeTruthy())
expect(screen.getByText(/1 ripped/i)).toBeTruthy()
expect(screen.getByText(/2 not ripped/i)).toBeTruthy()
})
it('artist index sets the search box to the artist name', async () => {
renderLibrary()
const artistBtn = await screen.findByRole('button', { name: /massive attack/i })
await userEvent.click(artistBtn)
await waitFor(() =>
expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'Massive Attack' }))
)
})
it('shows an empty state when the collection is empty', async () => {
vi.mocked(api.listCollection).mockResolvedValue({ items: [], counts: { total: 0, ripped: 0, notRipped: 0 } } as never)
renderLibrary()
await waitFor(() => expect(screen.getByText(/nothing here yet/i)).toBeTruthy())
expect(screen.getByRole('link', { name: /add your first record/i }).getAttribute('href')).toBe('/add')
})
})

26
web/test/pwa.test.ts Normal file
View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import path from 'node:path'
const pub = path.resolve(__dirname, '../public')
describe('PWA assets', () => {
it('manifest has required fields and the icon exists', () => {
const manifest = JSON.parse(readFileSync(path.join(pub, 'manifest.webmanifest'), 'utf8')) as {
name: string
display: string
start_url: string
icons: { src: string; sizes: string; type: string }[]
}
expect(manifest.name).toContain('record-shop')
expect(manifest.display).toBe('standalone')
expect(manifest.start_url).toBe('/')
expect(manifest.icons.some((i) => i.src === '/icon.svg')).toBe(true)
expect(() => readFileSync(path.join(pub, 'icon.svg'))).not.toThrow()
})
it('service worker registers a fetch handler', () => {
const sw = readFileSync(path.join(pub, 'sw.js'), 'utf8')
expect(sw).toContain("addEventListener('fetch'")
})
})

168
web/test/reducer.test.ts Normal file
View File

@@ -0,0 +1,168 @@
import { describe, it, expect } from 'vitest'
import { scanReducer, INITIAL_SCAN_STATE, type ScanState } from '../src/scan/reducer.js'
import type { Candidate, Item, ReleasePreview } from '../src/types.js'
const candidate: Candidate = {
id: 1001,
artist: 'The Cinematic Orchestra',
title: 'Motion',
year: 1999,
formats: ['CD'],
labels: ['Ninja Tune'],
country: 'UK',
catno: 'ZENCD012',
thumbUrl: 'https://img/x.jpg',
}
const preview: ReleasePreview = {
release: { ...candidate, genres: [], tracklist: [], coverUrl: null, barcodes: ['5021592210629'] },
duplicate: false,
ripMatch: 'not_ripped',
matchCandidates: [],
}
const item: Item = {
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',
}
function stateOf(phase: ScanState['phase']): ScanState {
let state = INITIAL_SCAN_STATE
const steps: Parameters<typeof scanReducer>[1][] = [
{ type: 'DETECT', code: '5021592210629' },
{ type: 'CANDIDATES', code: '5021592210629', candidates: [candidate] },
{ type: 'SELECT', candidate },
{ type: 'PREVIEW', preview, candidateId: 1001 },
{ type: 'ADD_START' },
{ type: 'ADDED', item },
]
const order: ScanState['phase'][] = ['scan', 'looking', 'candidates', 'confirm', 'confirm', 'confirm', 'added']
const idx = order.indexOf(phase)
if (idx < 0) return state
for (let i = 1; i <= idx; i++) state = scanReducer(state, steps[i - 1]!)
return state
}
describe('scanReducer', () => {
it('DETECT from scan → looking', () => {
const next = scanReducer(INITIAL_SCAN_STATE, { type: 'DETECT', code: '123' })
expect(next).toEqual({ phase: 'looking', code: '123' })
})
it('DETECT is ignored unless scanning (prevents duplicate lookups)', () => {
const looking = stateOf('looking')
expect(scanReducer(looking, { type: 'DETECT', code: '999' })).toBe(looking)
})
it('CANDIDATES from looking → candidates', () => {
const next = scanReducer(stateOf('looking'), {
type: 'CANDIDATES',
code: '5021592210629',
candidates: [candidate],
})
expect(next.phase).toBe('candidates')
})
it('NOT_FOUND from looking → error not_found', () => {
const next = scanReducer(stateOf('looking'), { type: 'NOT_FOUND', code: '123' })
expect(next).toEqual({ phase: 'error', kind: 'not_found', code: '123' })
})
it('ERROR maps kinds', () => {
const next = scanReducer(stateOf('looking'), {
type: 'ERROR',
kind: 'no_discogs_token',
code: '123',
source: 'lookup',
})
expect(next).toEqual({ phase: 'error', kind: 'no_discogs_token', code: '123' })
})
it('ignores a stale preview for a different candidate', () => {
const confirm = stateOf('confirm')
const stale = scanReducer(confirm, { type: 'PREVIEW', preview, candidateId: 999 })
expect(stale).toBe(confirm)
})
it('ignores a preview-source error in the looking phase', () => {
const looking = stateOf('looking')
const next = scanReducer(looking, {
type: 'ERROR', kind: 'server', code: null, source: 'preview', candidateId: 1001,
})
expect(next).toBe(looking)
})
it('ignores a lookup-source error after the user moved past looking', () => {
const candidates = stateOf('candidates')
const next = scanReducer(candidates, {
type: 'ERROR', kind: 'server', code: '123', source: 'lookup',
})
expect(next).toBe(candidates)
})
it('SELECT from candidates → confirm with candidate, no preview yet', () => {
const next = scanReducer(stateOf('candidates'), { type: 'SELECT', candidate })
expect(next.phase).toBe('confirm')
if (next.phase === 'confirm') {
expect(next.candidate).toBe(candidate)
expect(next.preview).toBeNull()
expect(next.adding).toBe(false)
expect(next.matchAlbumId).toBeNull()
}
})
it('PREVIEW fills the confirm phase', () => {
const next = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 })
if (next.phase === 'confirm') expect(next.preview).toBe(preview)
else throw new Error('expected confirm')
})
it('SET_MATCH / CLEAR_MATCH only in confirm', () => {
const confirm = stateOf('confirm')
const set = scanReducer(confirm, { type: 'SET_MATCH', albumId: 7 })
if (set.phase === 'confirm') expect(set.matchAlbumId).toBe(7)
const cleared = scanReducer(set, { type: 'CLEAR_MATCH' })
if (cleared.phase === 'confirm') expect(cleared.matchAlbumId).toBeNull()
expect(scanReducer(stateOf('scan'), { type: 'SET_MATCH', albumId: 7 })).toBe(stateOf('scan'))
})
it('ADD_START → ADDED', () => {
const confirm = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 })
const starting = scanReducer(confirm, { type: 'ADD_START' })
if (starting.phase === 'confirm') expect(starting.adding).toBe(true)
const added = scanReducer(starting, { type: 'ADDED', item })
expect(added).toEqual({ phase: 'added', item })
})
it('ADD_ERROR stores the message without leaving confirm', () => {
const confirm = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 })
const starting = scanReducer(confirm, { type: 'ADD_START' })
const next = scanReducer(starting, { type: 'ADD_ERROR', message: 'duplicate' })
if (next.phase === 'confirm') {
expect(next.adding).toBe(false)
expect(next.addError).toBe('duplicate')
} else {
throw new Error('expected confirm')
}
})
it('RESET returns to scan from anywhere', () => {
for (const phase of ['looking', 'candidates', 'confirm', 'added', 'error'] as const) {
expect(scanReducer(stateOf(phase), { type: 'RESET' })).toEqual({ phase: 'scan' })
}
})
})

99
web/test/router.test.tsx Normal file
View File

@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import App from '../src/App'
const fetchMock = vi.fn()
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
window.history.replaceState(null, '', '/')
})
function json(status: number, body: unknown) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
function loggedInServer() {
fetchMock.mockImplementation((url: string) => {
if (url === '/api/setup') return Promise.resolve(json(200, { needed: false }))
if (url === '/api/me') return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } }))
return Promise.resolve(json(404, { error: 'not_found' }))
})
}
describe('routing', () => {
it('shows the setup form when the server needs setup', async () => {
fetchMock.mockImplementation((url: string) =>
url === '/api/setup' ? Promise.resolve(json(200, { needed: true })) : Promise.resolve(json(404, {}))
)
render(<App />)
await waitFor(() => expect(screen.getByRole('heading', { name: /welcome/i })).toBeTruthy())
})
it('shows the login form when unauthenticated', async () => {
fetchMock.mockImplementation((url: string) => {
if (url === '/api/setup') return Promise.resolve(json(200, { needed: false }))
return Promise.resolve(json(401, { error: 'unauthorized' }))
})
render(<App />)
await waitFor(() => expect(screen.getByRole('heading', { name: /sign in/i })).toBeTruthy())
})
it('lands on Library with the tab bar when authenticated', async () => {
loggedInServer()
render(<App />)
await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy())
for (const tab of ['Library', 'Scan', 'Add', 'Settings']) {
expect(screen.getByRole('tab', { name: new RegExp(tab, 'i') })).toBeTruthy()
}
})
it('login form authenticates and enters the app', async () => {
fetchMock.mockImplementation((url: string) => {
if (url === '/api/setup') return Promise.resolve(json(200, { needed: false }))
if (url === '/api/me') return Promise.resolve(json(401, { error: 'unauthorized' }))
if (url === '/api/login') {
return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } }))
}
return Promise.resolve(json(404, {}))
})
render(<App />)
await waitFor(() => expect(screen.getByLabelText(/username/i)).toBeTruthy())
await userEvent.type(screen.getByLabelText(/username/i), 'sam')
await userEvent.type(screen.getByLabelText(/password/i), 'password123')
await userEvent.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy())
})
it('setup form creates the admin account and enters the app', async () => {
fetchMock.mockImplementation((url: string, init?: RequestInit) => {
if (url === '/api/setup' && (!init || !init.method || init.method === 'GET')) {
return Promise.resolve(json(200, { needed: true }))
}
if (url === '/api/setup' && init?.method === 'POST') {
return Promise.resolve(json(200, { user: { id: 1, username: 'boss', isAdmin: true } }))
}
return Promise.resolve(json(404, {}))
})
render(<App />)
await waitFor(() => expect(screen.getByLabelText(/username/i)).toBeTruthy())
await userEvent.type(screen.getByLabelText(/username/i), 'boss')
await userEvent.type(screen.getByLabelText(/password/i), 'password123')
await userEvent.click(screen.getByRole('button', { name: /create/i }))
await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy())
})
it('tab navigation switches pages', async () => {
loggedInServer()
render(<App />)
await waitFor(() => expect(screen.getByRole('tab', { name: /settings/i })).toBeTruthy())
await userEvent.click(screen.getByRole('tab', { name: /settings/i }))
await waitFor(() => expect(screen.getByRole('heading', { name: /settings/i })).toBeTruthy())
expect(screen.getByText(/account/i)).toBeTruthy()
})
})

188
web/test/scanPage.test.tsx Normal file
View File

@@ -0,0 +1,188 @@
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, Item, ReleasePreview } from '../src/types.js'
vi.mock('../src/components/Scanner.js', () => ({
default: ({ onDetect }: { onDetect: (code: string) => void }) => (
<button type="button" onClick={() => onDetect('5021592210629')}>
fake-scan
</button>
),
}))
vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/api.js')>()
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.
const addedItem: Item = {
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',
}
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(
<MemoryRouter initialEntries={['/scan']}>
<ScanPage />
</MemoryRouter>
)
}
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(addedItem) 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')
})
it('does not add twice when the button is clicked rapidly', async () => {
let resolveAdd: (v: unknown) => void = () => {}
vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never)
vi.mocked(api.addToCollection).mockImplementation(
() => new Promise((resolve) => { resolveAdd = resolve }) as never
)
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
const addBtn = await screen.findByRole('button', { name: /add to collection/i })
await userEvent.click(addBtn)
// adding=true → button is disabled ('Adding…'); a second rapid click must not re-fire the request
await userEvent.click(addBtn).catch(() => {})
resolveAdd(addedItem)
await waitFor(() => expect(screen.getByText(/added to collection/i)).toBeTruthy())
expect(api.addToCollection).toHaveBeenCalledTimes(1)
})
it('distinguishes an invalid discogs token with a settings link', async () => {
vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(502, 'discogs_auth'))
renderScan()
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
await waitFor(() => expect(screen.getByText(/rejected your token/i)).toBeTruthy())
expect(screen.getByRole('link', { name: /settings/i }).getAttribute('href')).toBe('/settings')
})
})

88
web/test/scanner.test.ts Normal file
View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useBarcodeScanner, shouldEmit } from '../src/hooks/useBarcodeScanner.js'
function fakeStream(): MediaStream {
const tracks: MediaStreamTrack[] = [{ stop: vi.fn() } as unknown as MediaStreamTrack]
return { getTracks: () => tracks } as unknown as MediaStream
}
function stubMediaDevices(impl: () => Promise<MediaStream>) {
vi.stubGlobal(
'navigator',
Object.assign(Object.create(Object.getPrototypeOf(navigator)), navigator, {
mediaDevices: { getUserMedia: impl },
})
)
}
beforeEach(() => {
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('shouldEmit (cooldown dedup)', () => {
it('emits a new code, suppresses a rapid repeat, emits after cooldown', () => {
expect(shouldEmit(null, '123', 0, 1500)).toBe(true)
expect(shouldEmit({ code: '123', at: 100 }, '123', 500, 1500)).toBe(false)
expect(shouldEmit({ code: '123', at: 100 }, '456', 500, 1500)).toBe(true)
expect(shouldEmit({ code: '123', at: 100 }, '123', 1700, 1500)).toBe(true)
})
})
describe('useBarcodeScanner', () => {
it('detects a barcode via BarcodeDetector and calls onDetect', async () => {
const detections = [[], [{ rawValue: '5021592210629', format: 'ean_13' }]]
let call = 0
class FakeDetector {
constructor(_opts: { formats: string[] }) {
expect(_opts.formats).toEqual(['ean_13', 'upc_a', 'ean_8'])
}
async detect() {
return detections[Math.min(call++, 1)]!
}
}
;(window as unknown as { BarcodeDetector?: unknown }).BarcodeDetector = FakeDetector
stubMediaDevices(() => Promise.resolve(fakeStream()))
const onDetect = vi.fn()
const videoRef = { current: document.createElement('video') } as React.RefObject<HTMLVideoElement>
const { result } = renderHook(() => useBarcodeScanner(videoRef, onDetect, true))
await waitFor(() => expect(result.current.status).toBe('ready'))
await waitFor(() => expect(onDetect).toHaveBeenCalledWith('5021592210629'))
})
it('reports denied when camera permission is rejected', async () => {
stubMediaDevices(() =>
Promise.reject(new DOMException('denied', 'NotAllowedError'))
)
const videoRef = { current: document.createElement('video') } as React.RefObject<HTMLVideoElement>
const { result } = renderHook(() => useBarcodeScanner(videoRef, () => {}, true))
await waitFor(() => expect(result.current.status).toBe('denied'))
})
it('stops the stream when enabled goes false', async () => {
const stream = fakeStream()
const stopSpy = vi.spyOn(stream.getTracks()[0]!, 'stop')
stubMediaDevices(() => Promise.resolve(stream))
;(window as unknown as { BarcodeDetector?: unknown }).BarcodeDetector = class {
async detect() {
return []
}
}
const videoRef = { current: document.createElement('video') } as React.RefObject<HTMLVideoElement>
const { result, rerender } = renderHook(
({ enabled }) => useBarcodeScanner(videoRef, () => {}, enabled),
{ initialProps: { enabled: true } }
)
await waitFor(() => expect(result.current.status).toBe('ready'))
rerender({ enabled: false })
expect(stopSpy).toHaveBeenCalled()
expect(result.current.status).toBe('idle')
})
})

182
web/test/settings.test.tsx Normal file
View File

@@ -0,0 +1,182 @@
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 { AuthProvider } from '../src/auth.js'
import SettingsPage from '../src/pages/SettingsPage.js'
import type { SettingsView, SyncState, User } from '../src/types.js'
vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/api.js')>()
return {
...actual,
api: {
...actual.api,
getSettings: vi.fn(),
putSettings: vi.fn(),
syncStatus: vi.fn(),
startSync: vi.fn(),
listUsers: vi.fn(),
createUser: vi.fn(),
deleteUser: vi.fn(),
logout: vi.fn(),
},
}
})
import { api } from '../src/api.js'
const emptyView: SettingsView = {
hasDiscogsToken: false,
discogsTokenMasked: null,
subsonicUrl: null,
subsonicUsername: null,
hasSubsonicPassword: false,
}
const idleSync: SyncState = { status: 'idle', error: null, lastSyncedAt: null, albums: 0 }
const admin: User = { id: 1, username: 'sam', isAdmin: true }
function jsonOk(body: unknown) {
return Promise.resolve(
new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
)
}
// AuthProvider reads /api/setup and /api/me via fetch; stub an authed session
function stubAuthFetch(user: User) {
vi.stubGlobal(
'fetch',
vi.fn((url: string) => {
if (url === '/api/setup') return jsonOk({ needed: false })
return jsonOk({ user })
})
)
}
beforeEach(() => {
for (const fn of [api.getSettings, api.putSettings, api.syncStatus, api.startSync, api.listUsers, api.createUser, api.deleteUser, api.logout] as const) {
vi.mocked(fn).mockReset()
}
vi.mocked(api.getSettings).mockResolvedValue(emptyView as never)
vi.mocked(api.syncStatus).mockResolvedValue(idleSync as never)
vi.mocked(api.listUsers).mockResolvedValue({ users: [admin] } as never)
stubAuthFetch(admin)
})
function renderSettings() {
return render(
<MemoryRouter>
<AuthProvider>
<SettingsPage />
</AuthProvider>
</MemoryRouter>
)
}
describe('SettingsPage', () => {
it('saves the discogs token and shows the mask', async () => {
vi.mocked(api.putSettings).mockResolvedValue({ ...emptyView, hasDiscogsToken: true, discogsTokenMasked: '****6789' } as never)
renderSettings()
await userEvent.type(await screen.findByLabelText(/discogs token/i), 'abcdef0123456789')
await userEvent.click(screen.getByRole('button', { name: /save discogs/i }))
await waitFor(() => expect(api.putSettings).toHaveBeenCalledWith({ discogsToken: 'abcdef0123456789' }))
await waitFor(() => expect(screen.getByText(/token saved/i)).toBeTruthy())
})
it('clears the discogs token with an empty save', async () => {
vi.mocked(api.getSettings).mockResolvedValue({ ...emptyView, hasDiscogsToken: true, discogsTokenMasked: '****6789' } as never)
vi.mocked(api.putSettings).mockResolvedValue(emptyView as never)
renderSettings()
await userEvent.click(await screen.findByRole('button', { name: /save discogs/i }))
await waitFor(() => expect(api.putSettings).toHaveBeenCalledWith({ discogsToken: '' }))
})
it('saves subsonic config and surfaces validation errors', async () => {
const err = new (await import('../src/api.js')).ApiError(400, 'subsonic_unreachable', 'could not reach http://x')
vi.mocked(api.putSettings).mockRejectedValue(err)
renderSettings()
await userEvent.type(await screen.findByLabelText(/subsonic url/i), 'http://x')
await userEvent.type(screen.getByLabelText(/subsonic username/i), 'sam')
await userEvent.type(screen.getByLabelText(/subsonic password/i), 'pass')
await userEvent.click(screen.getByRole('button', { name: /save subsonic/i }))
await waitFor(() => expect(screen.getByText(/could not reach http:\/\/x/i)).toBeTruthy())
})
it('shows sync state and triggers a sync', async () => {
vi.mocked(api.startSync).mockResolvedValue({ ...idleSync, status: 'running' } as never)
vi.mocked(api.syncStatus).mockResolvedValue({
status: 'done',
error: null,
lastSyncedAt: '2026-08-29T12:00:00.000Z',
albums: 503,
} as never)
renderSettings()
await userEvent.click(await screen.findByRole('button', { name: /sync now/i }))
await waitFor(() => expect(api.startSync).toHaveBeenCalled())
await waitFor(() => expect(screen.getByText(/503 albums/i)).toBeTruthy())
})
it('admin manages users', async () => {
vi.mocked(api.createUser).mockResolvedValue({ id: 2, username: 'bob', isAdmin: false } as never)
renderSettings()
expect((await screen.findAllByText('sam')).length).toBeGreaterThan(0)
await userEvent.type(screen.getByLabelText(/new username/i), 'bob')
await userEvent.type(screen.getByLabelText(/new password/i), 'bobpass123')
await userEvent.click(screen.getByRole('button', { name: /add user/i }))
await waitFor(() => expect(api.createUser).toHaveBeenCalledWith('bob', 'bobpass123'))
expect(await screen.findByText('bob')).toBeTruthy()
vi.mocked(api.deleteUser).mockResolvedValue({ ok: true } as never)
await userEvent.click(screen.getByRole('button', { name: /remove bob/i }))
await waitFor(() => expect(api.deleteUser).toHaveBeenCalledWith(2))
})
it('hides user management from non-admins', async () => {
stubAuthFetch({ id: 2, username: 'bob', isAdmin: false })
renderSettings()
expect(await screen.findByText('bob')).toBeTruthy()
expect(screen.queryByLabelText(/discogs token/i)).toBeTruthy()
expect(screen.queryByLabelText(/new username/i)).toBeNull()
})
it('logout button calls the api', async () => {
vi.mocked(api.logout).mockResolvedValue({ ok: true } as never)
renderSettings()
await userEvent.click(await screen.findByRole('button', { name: /log out/i }))
expect(api.logout).toHaveBeenCalled()
})
it('sync now surfaces the no-config error', async () => {
const err = new (await import('../src/api.js')).ApiError(409, 'no_subsonic_config')
vi.mocked(api.startSync).mockRejectedValue(err)
renderSettings()
await userEvent.click(await screen.findByRole('button', { name: /sync now/i }))
await waitFor(() => expect(screen.getByText(/no_subsonic_config/)).toBeTruthy())
})
it('keeps the stored subsonic password when saving with a blank password field', async () => {
vi.mocked(api.putSettings).mockResolvedValue(emptyView as never)
renderSettings()
await userEvent.type(await screen.findByLabelText(/subsonic url/i), 'http://navidrome.local')
await userEvent.type(screen.getByLabelText(/subsonic username/i), 'sam')
// password left blank
await userEvent.click(screen.getByRole('button', { name: /save subsonic/i }))
await waitFor(() =>
expect(api.putSettings).toHaveBeenCalledWith({ subsonicUrl: 'http://navidrome.local', subsonicUsername: 'sam' })
)
})
it('polls sync status until it finishes after sync now', async () => {
vi.mocked(api.startSync).mockResolvedValue({ ...idleSync, status: 'running' } as never)
vi.mocked(api.syncStatus)
.mockResolvedValueOnce({ ...idleSync } as never) // mount poll: idle, chain ends
.mockResolvedValueOnce({ ...idleSync, status: 'running' } as never) // first poll after sync now
.mockResolvedValue({ ...idleSync, status: 'done', albums: 42, lastSyncedAt: '2026-08-29T12:00:00.000Z' } as never)
renderSettings()
await userEvent.click(await screen.findByRole('button', { name: /sync now/i }))
await waitFor(() => expect(screen.getByText(/42 albums synced/i)).toBeTruthy(), { timeout: 3000 })
expect(vi.mocked(api.syncStatus).mock.calls.length).toBeGreaterThanOrEqual(3)
})
})

6
web/test/setup.ts Normal file
View File

@@ -0,0 +1,6 @@
import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
afterEach(() => {
cleanup()
})

31
web/test/smoke.test.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import App from '../src/App'
const fetchMock = vi.fn()
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
window.history.replaceState(null, '', '/')
})
function json(status: number, body: unknown) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
describe('App', () => {
it('renders the app title', async () => {
fetchMock.mockImplementation((url: string) => {
if (url === '/api/setup') return Promise.resolve(json(200, { needed: false }))
if (url === '/api/me')
return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } }))
return Promise.resolve(json(404, {}))
})
render(<App />)
await waitFor(() => expect(screen.getByRole('heading', { name: 'record-shop' })).toBeTruthy())
})
})

19
web/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmit": true,
"isolatedModules": true,
"types": ["vite/client"]
},
"include": ["src", "test", "vite.config.ts"]
}

17
web/vite.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
root: import.meta.dirname,
server: {
proxy: {
'/api': 'http://localhost:3000',
'/artwork': 'http://localhost:3000',
},
},
build: {
outDir: 'dist',
},
})