| |
| import type { |
| AdminCaseDetail, |
| AdminCasesPage, |
| CaseDog, |
| AdminDog, |
| AdminDogsPage, |
| AdminOwnerDetail, |
| AdminOwnersPage, |
| BreedOption, |
| BreedEstimateResult, |
| DogDetailResult, |
| PhotoSearchResult, |
| TestMatchResult, |
| Case, |
| Dataset, |
| DatasetDetail, |
| DatasetDogsPage, |
| EmbedAllResult, |
| FoundReportResponse, |
| Job, |
| KnownDog, |
| Match, |
| MatchDatasetResult, |
| Page, |
| PurgeResult, |
| ReclaimInfo, |
| User, |
| } from "./types"; |
|
|
| const TOKEN_KEY = "pawtrace_token"; |
|
|
| export function getToken(): string | null { |
| return localStorage.getItem(TOKEN_KEY); |
| } |
| export function setToken(t: string | null) { |
| if (t) localStorage.setItem(TOKEN_KEY, t); |
| else localStorage.removeItem(TOKEN_KEY); |
| } |
|
|
| export class ApiError extends Error { |
| status: number; |
| constructor(status: number, message: string) { |
| super(message); |
| this.status = status; |
| } |
| } |
|
|
| async function request<T>(path: string, opts: RequestInit = {}): Promise<T> { |
| const headers = new Headers(opts.headers); |
| const token = getToken(); |
| if (token) headers.set("Authorization", `Bearer ${token}`); |
| if (!(opts.body instanceof FormData) && opts.body) { |
| headers.set("Content-Type", "application/json"); |
| } |
| const res = await fetch(path, { ...opts, headers }); |
| if (res.status === 204) return undefined as T; |
| const data = await res.json().catch(() => ({})); |
| if (!res.ok) { |
| const msg = data?.error?.message || data?.detail || res.statusText; |
| throw new ApiError(res.status, msg); |
| } |
| return data as T; |
| } |
|
|
| export const api = { |
| |
| register: (body: { name: string; email: string; password: string; zip: string; phone?: string }) => |
| request<{ access_token: string }>("/auth/register", { method: "POST", body: JSON.stringify(body) }), |
| login: (body: { email: string; password: string }) => |
| request<{ access_token: string }>("/auth/login", { method: "POST", body: JSON.stringify(body) }), |
| me: () => request<User>("/auth/me"), |
|
|
| |
| listDogs: () => request<Page<KnownDog>>("/dogs"), |
| getDog: (id: number) => request<KnownDog>(`/dogs/${id}`), |
| createDog: (body: Partial<KnownDog>) => |
| request<KnownDog>("/dogs", { method: "POST", body: JSON.stringify(body) }), |
| uploadDogPhotos: (id: number, files: File[]) => { |
| const fd = new FormData(); |
| files.forEach((f) => fd.append("files", f)); |
| return request<unknown>(`/dogs/${id}/photos`, { method: "POST", body: fd }); |
| }, |
| markDogHome: (id: number) => request<KnownDog>(`/dogs/${id}/mark-home`, { method: "POST" }), |
|
|
| |
| createLost: (body: Record<string, unknown>) => |
| request<FoundReportResponse>("/cases/lost", { method: "POST", body: JSON.stringify(body) }), |
| createFound: (fd: FormData) => |
| request<FoundReportResponse>("/cases/found", { method: "POST", body: fd }), |
| listCases: () => request<Page<Case>>("/cases"), |
| getCase: (id: number) => request<Case>(`/cases/${id}`), |
| getCaseMatches: (id: number) => request<Match[]>(`/cases/${id}/matches`), |
| getCaseDog: (id: number) => request<CaseDog>(`/cases/${id}/dog`), |
| widen: (id: number) => request<FoundReportResponse>(`/cases/${id}/widen`, { method: "POST" }), |
| widenBreed: (id: number) => |
| request<FoundReportResponse>(`/cases/${id}/widen-breed`, { method: "POST" }), |
| rematchCase: (id: number) => |
| request<FoundReportResponse>(`/cases/${id}/rematch`, { method: "POST" }), |
| updateCase: (id: number, body: { notes?: string; close?: boolean }) => |
| request<Case>(`/cases/${id}`, { method: "PATCH", body: JSON.stringify(body) }), |
|
|
| |
| confirmMatch: (id: number) => request<Match>(`/matches/${id}/confirm`, { method: "POST" }), |
| rejectMatch: (id: number) => request<Match>(`/matches/${id}/reject`, { method: "POST" }), |
| reconsiderMatch: (id: number) => request<Match>(`/matches/${id}/reconsider`, { method: "POST" }), |
| reclaimInfo: (id: number) => request<ReclaimInfo>(`/matches/${id}/reclaim`), |
|
|
| |
| shelters: (zip?: string) => |
| request<{ zip: string | null; shelters: { name: string; detail: string }[] }>( |
| `/geo/nearby-shelters${zip ? `?zip=${encodeURIComponent(zip)}` : ""}` |
| ), |
|
|
| |
| getConfig: () => request<{ demo_mode: boolean; haystack_size: number }>("/config"), |
|
|
| |
| |
| |
| |
| searchByPhoto: (files: File[], zip?: string, pool: "found" | "lost" = "found", topK = 12) => { |
| const fd = new FormData(); |
| files.forEach((f) => fd.append("files", f)); |
| if (zip) fd.append("zip", zip); |
| fd.append("top_k", String(topK)); |
| fd.append("pool", pool); |
| return request<PhotoSearchResult>("/search/by-photo", { method: "POST", body: fd }); |
| }, |
| |
| |
| estimateBreed: (files: File[], topN = 5) => { |
| const fd = new FormData(); |
| files.forEach((f) => fd.append("files", f)); |
| fd.append("top_n", String(topN)); |
| return request<BreedEstimateResult>("/search/breed", { method: "POST", body: fd }); |
| }, |
|
|
| |
| listAllDogs: ( |
| kind: "all" | "known" | "unknown" = "all", |
| limit = 30, |
| offset = 0, |
| opts: { |
| breed?: string; |
| breedK?: number; |
| sort?: "newest" | "oldest"; |
| zip?: string; |
| addedFrom?: string; |
| addedTo?: string; |
| } = {} |
| ) => { |
| const p = new URLSearchParams({ kind, limit: String(limit), offset: String(offset) }); |
| if (opts.breed) { |
| p.set("breed", opts.breed); |
| p.set("breed_k", String(opts.breedK ?? 10)); |
| } |
| if (opts.sort) p.set("sort", opts.sort); |
| if (opts.zip) p.set("zip", opts.zip); |
| if (opts.addedFrom) p.set("added_from", opts.addedFrom); |
| if (opts.addedTo) p.set("added_to", opts.addedTo); |
| return request<AdminDogsPage>(`/admin/dogs?${p}`); |
| }, |
| listBreeds: () => request<{ model: [string, string] | null; breeds: BreedOption[] }>("/admin/breeds"), |
| listAllCases: (kind?: "lost" | "found", status?: string, limit = 30, offset = 0) => { |
| const q = new URLSearchParams({ limit: String(limit), offset: String(offset) }); |
| if (kind) q.set("kind", kind); |
| if (status) q.set("status", status); |
| return request<AdminCasesPage>(`/admin/cases?${q}`); |
| }, |
| listOwners: (limit = 30, offset = 0, q?: string) => { |
| const p = new URLSearchParams({ limit: String(limit), offset: String(offset) }); |
| if (q) p.set("q", q); |
| return request<AdminOwnersPage>(`/admin/owners?${p}`); |
| }, |
| getOwnerDetail: (id: number) => request<AdminOwnerDetail>(`/admin/owners/${id}`), |
| resetDemo: () => |
| request<{ matches_reset: number; cases_reopened: number; known_relost: number; found_reset: number }>( |
| "/admin/reset-demo", |
| { method: "POST" }, |
| ), |
| getCaseDetail: (caseId: number) => request<AdminCaseDetail>(`/admin/cases/${caseId}`), |
| runCaseMatch: (caseId: number) => |
| request<Match[]>(`/admin/cases/${caseId}/run-match`, { method: "POST" }), |
| deleteCase: (caseId: number) => |
| request<{ deleted: Record<string, number> }>(`/admin/cases/${caseId}`, { method: "DELETE" }), |
| deleteDog: (kind: "known" | "unknown", dogId: number) => |
| request<{ deleted: Record<string, number> }>(`/admin/dogs/${kind}/${dogId}`, { method: "DELETE" }), |
| setDogStatus: (kind: "known" | "unknown", dogId: number, status: string) => |
| request<{ profile: AdminDog }>(`/admin/dogs/${kind}/${dogId}/status`, { |
| method: "POST", |
| body: JSON.stringify({ status }), |
| }), |
| deletePerson: (userId: number) => |
| request<{ deleted: Record<string, number> }>(`/admin/owners/${userId}`, { method: "DELETE" }), |
| testMatch: (kind: "known" | "unknown", dogId: number, topK = 10, applyBreedGate = false) => |
| request<TestMatchResult>( |
| `/admin/test-match?kind=${kind}&dog_id=${dogId}&top_k=${topK}&apply_breed_gate=${applyBreedGate}` |
| ), |
| getDogDetail: (kind: "known" | "unknown", dogId: number) => |
| request<DogDetailResult>(`/admin/dog/${kind}/${dogId}`), |
| listDatasets: () => request<Dataset[]>("/admin/datasets"), |
| getDataset: (id: number) => request<DatasetDetail>(`/admin/datasets/${id}`), |
| listDatasetDogs: (id: number, limit = 25, offset = 0) => |
| request<DatasetDogsPage>(`/admin/datasets/${id}/dogs?limit=${limit}&offset=${offset}`), |
| deleteDataset: (id: number) => |
| request<PurgeResult>(`/admin/datasets/${id}`, { method: "DELETE" }), |
| embedAll: (id: number) => |
| request<EmbedAllResult>(`/admin/datasets/${id}/embed-all`, { method: "POST" }), |
| startEmbedJob: (id: number) => |
| request<{ job_id: string; status: string }>(`/admin/datasets/${id}/embed-all-job`, { |
| method: "POST", |
| }), |
| matchDataset: (id: number, candidateDatasetId?: number) => |
| request<MatchDatasetResult>( |
| `/admin/datasets/${id}/match${candidateDatasetId ? `?candidate_dataset_id=${candidateDatasetId}` : ""}`, |
| { method: "POST" } |
| ), |
| startLoad: (fd: FormData) => |
| request<{ job_id: string; status: string }>("/admin/datasets/load", { method: "POST", body: fd }), |
| getJob: (jobId: string) => request<Job>(`/admin/jobs/${jobId}`), |
| }; |
|
|