1
0

feat: typed api client and auth context with setup gate

This commit is contained in:
2026-08-29 19:34:02 +02:00
parent 63e8c5f054
commit fdf200a960
4 changed files with 308 additions and 0 deletions

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

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

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

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

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

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