Compare commits
19 Commits
b55a1f8cc1
...
3fa0b02560
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fa0b02560 | |||
| 21a4c69972 | |||
| f2fff1d7be | |||
| c1852435c8 | |||
| 3efde1a828 | |||
| 6b85f11871 | |||
| 7a58f9e5a0 | |||
| bc62019162 | |||
| 71423c7ab7 | |||
| e912d9e485 | |||
| b87e6abee0 | |||
| b9ba4f0ca6 | |||
| c76d936f1e | |||
| 890e7da3a7 | |||
| 70b7fdbe1b | |||
| bda43ce237 | |||
| fdf200a960 | |||
| 63e8c5f054 | |||
| 26609d8b62 |
@@ -12,7 +12,8 @@ RUN npm ci
|
|||||||
|
|
||||||
COPY tsconfig.json vitest.config.ts ./
|
COPY tsconfig.json vitest.config.ts ./
|
||||||
COPY server ./server
|
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
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
# ---- runtime stage ----
|
# ---- runtime stage ----
|
||||||
@@ -23,6 +24,7 @@ WORKDIR /app
|
|||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/server/dist ./server/dist
|
COPY --from=build /app/server/dist ./server/dist
|
||||||
|
COPY --from=build /app/web/dist ./web/dist
|
||||||
|
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV DATA_DIR=/data
|
ENV DATA_DIR=/data
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ token and (optionally) your Subsonic server details in Settings.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm test # vitest
|
npm test # vitest (server + web)
|
||||||
npm run dev:server
|
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`
|
Spec: `docs/superpowers/specs/2026-08-29-record-shop-design.md`
|
||||||
|
|||||||
3992
docs/superpowers/plans/2026-08-29-record-shop-frontend.md
Normal file
3992
docs/superpowers/plans/2026-08-29-record-shop-frontend.md
Normal file
File diff suppressed because it is too large
Load Diff
2305
package-lock.json
generated
2305
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
@@ -3,29 +3,48 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": { "node": ">=20" },
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "tsx watch server/src/index.ts",
|
"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": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"build:server": "tsc -p server/tsconfig.build.json",
|
"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",
|
"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": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^8.0.0",
|
||||||
|
"@zxing/library": "^0.23.0",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
"better-sqlite3": "^13.0.3",
|
"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": {
|
"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/better-sqlite3": "^7.6.11",
|
||||||
"@types/node": "^22.5.0",
|
"@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",
|
"tsx": "^4.16.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^7.3.6",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,22 @@ import { defineConfig } from 'vitest/config'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
test: {
|
||||||
|
name: 'server',
|
||||||
include: ['server/test/**/*.test.ts'],
|
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
16
web/index.html
Normal 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
7
web/public/icon.svg
Normal 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 |
13
web/public/manifest.webmanifest
Normal file
13
web/public/manifest.webmanifest
Normal 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
11
web/public/sw.js
Normal 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
48
web/src/App.tsx
Normal 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
100
web/src/api.ts
Normal 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
70
web/src/auth.tsx
Normal 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
|
||||||
|
}
|
||||||
26
web/src/components/CandidateCard.tsx
Normal file
26
web/src/components/CandidateCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
web/src/components/Cover.tsx
Normal file
25
web/src/components/Cover.tsx
Normal 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}`} />
|
||||||
|
}
|
||||||
30
web/src/components/CoverGrid.tsx
Normal file
30
web/src/components/CoverGrid.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
web/src/components/Scanner.tsx
Normal file
39
web/src/components/Scanner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
web/src/hooks/useBarcodeScanner.ts
Normal file
121
web/src/hooks/useBarcodeScanner.ts
Normal 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
14
web/src/main.tsx
Normal 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
206
web/src/pages/AddPage.tsx
Normal 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
246
web/src/pages/ItemPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
web/src/pages/LibraryPage.tsx
Normal file
121
web/src/pages/LibraryPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
web/src/pages/LoginPage.tsx
Normal file
71
web/src/pages/LoginPage.tsx
Normal 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
213
web/src/pages/ScanPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
284
web/src/pages/SettingsPage.tsx
Normal file
284
web/src/pages/SettingsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
web/src/pages/SetupPage.tsx
Normal file
74
web/src/pages/SetupPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
web/src/scan/ConfirmView.tsx
Normal file
100
web/src/scan/ConfirmView.tsx
Normal 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
81
web/src/scan/reducer.ts
Normal 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
79
web/src/shell.tsx
Normal 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
1
web/src/styles.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
70
web/src/types.ts
Normal file
70
web/src/types.ts
Normal 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
92
web/test/add.test.tsx
Normal 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
68
web/test/auth.test.tsx
Normal 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
145
web/test/item.test.tsx
Normal 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
113
web/test/library.test.tsx
Normal 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
26
web/test/pwa.test.ts
Normal 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
168
web/test/reducer.test.ts
Normal 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
99
web/test/router.test.tsx
Normal 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
188
web/test/scanPage.test.tsx
Normal 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
88
web/test/scanner.test.ts
Normal 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
182
web/test/settings.test.tsx
Normal 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
6
web/test/setup.ts
Normal 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
31
web/test/smoke.test.tsx
Normal 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
19
web/tsconfig.json
Normal 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
17
web/vite.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user