feat: typed api client and auth context with setup gate
This commit is contained in:
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
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user