PawTrace / frontend /src /api.ts
Elliott Duke
Demo: multi-image search, breed-on-demand, Tech page, cleanup
a270696
Raw
History Blame Contribute Delete
9.5 kB
// Thin typed fetch wrapper. Token is injected from localStorage (JWT auth — DECISIONS.md).
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 = {
// auth
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"),
// dogs
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" }),
// cases
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) }),
// matches
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`),
// geo
shelters: (zip?: string) =>
request<{ zip: string | null; shelters: { name: string; detail: string }[] }>(
`/geo/nearby-shelters${zip ? `?zip=${encodeURIComponent(zip)}` : ""}`
),
// public runtime config (read on load to decide which UI to render + haystack size for the UI)
getConfig: () => request<{ demo_mode: boolean; haystack_size: number }>("/config"),
// public photo search (no auth). Accepts 1-6 images of the same dog — the server scores every
// query image against every candidate image and keeps the best pair, so more angles only help.
// pool: "found" searches found/unknown dogs (I lost my dog); "lost" searches known/lost dogs (I
// found a dog and want its owner).
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 });
},
// Breed estimation over 1-6 images of the same dog. The server averages the per-label scores
// across every image — one photo alone can easily flip the top breed.
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 });
},
// admin: datasets / data loading
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}`),
};