feat(web): api client and display formatters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a540208e1
commit
0bb871cd19
4 files changed
+604
No files matched your search
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* The `/api/v1` client. Cookie auth (`credentials: "same-origin"`); every non-GET request sends
|
||||
* `X-Requested-With: sttracker` (the server's CSRF rule) and, unless it is multipart, a JSON content type.
|
||||
* Wire types mirror the server's responses; the web app cannot import `@sttracker/core`.
|
||||
*/
|
||||
import type { PartialDate, Status } from "./format.ts";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
type Method = "GET" | "POST" | "PATCH" | "DELETE";
|
||||
type QueryValue = string | number | boolean | null | undefined;
|
||||
|
||||
export interface RequestOpts {
|
||||
body?: unknown;
|
||||
form?: FormData;
|
||||
query?: Record<string, QueryValue>;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function request<T = unknown>(method: Method, path: string, opts: RequestOpts = {}): Promise<T> {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
|
||||
const url = `/api/v1/${path}${qs.size ? `?${qs}` : ""}`;
|
||||
|
||||
const headers: Record<string, string> = { accept: "application/json" };
|
||||
let body: BodyInit | undefined;
|
||||
if (method !== "GET") {
|
||||
headers["x-requested-with"] = "sttracker";
|
||||
if (opts.form) body = opts.form;
|
||||
else {
|
||||
headers["content-type"] = "application/json";
|
||||
if (opts.body !== undefined) body = JSON.stringify(opts.body);
|
||||
}
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await (opts.fetchImpl ?? fetch)(url, { method, headers, body, credentials: "same-origin" });
|
||||
} catch {
|
||||
throw new ApiError(0, "network", "Can't reach the server. Check your connection and try again.");
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
let data: unknown;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : undefined;
|
||||
} catch {
|
||||
data = undefined;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = (data as { error?: { code?: string; message?: string } } | undefined)?.error;
|
||||
if (err?.code) throw new ApiError(res.status, err.code, err.message ?? err.code);
|
||||
throw new ApiError(res.status, `http_${res.status}`, `The server answered ${res.status}. Try again in a moment.`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Wire types
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
role: "admin" | "member";
|
||||
}
|
||||
|
||||
export interface EntryView {
|
||||
id: string;
|
||||
workId: string;
|
||||
status: Status;
|
||||
rating: number | null;
|
||||
review: string | null;
|
||||
privateNotes: string | null;
|
||||
favorite: boolean;
|
||||
tags: string[];
|
||||
customFields: Record<string, unknown>;
|
||||
visibility: "private" | "friends" | "public";
|
||||
overrides: { title?: string; coverUrl?: string; year?: number | null };
|
||||
mediaTypeOverride: string | null;
|
||||
fieldSources: Record<string, string>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
work: { id: string; mediaType: string; title: string; originalTitle: string; year: number | null; coverUrl: string; parentId: string | null };
|
||||
draft: boolean;
|
||||
childSummary: { total: number; completed: number } | null;
|
||||
}
|
||||
|
||||
export type EntryPatch = Partial<
|
||||
Pick<EntryView, "status" | "rating" | "review" | "privateNotes" | "favorite" | "tags" | "customFields" | "visibility" | "overrides">
|
||||
>;
|
||||
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface Engagement {
|
||||
id: string;
|
||||
entryId: string;
|
||||
startedAt: PartialDate | null;
|
||||
finishedAt: PartialDate | null;
|
||||
progress: Record<string, number | string>;
|
||||
platform: string | null;
|
||||
rating: number | null;
|
||||
notes: string | null;
|
||||
fieldSources: Record<string, string>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Copy {
|
||||
id: string;
|
||||
workId: string | null;
|
||||
titleHint: string | null;
|
||||
format: string | null;
|
||||
acquiredFrom: string | null;
|
||||
acquiredAt: PartialDate | null;
|
||||
priceCents: number | null;
|
||||
currency: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
mime: string;
|
||||
filename: string;
|
||||
caption: string | null;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
lastRunAt: string | null;
|
||||
hasCredentials: boolean;
|
||||
}
|
||||
|
||||
export interface ChangeEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
connectionId: string | null;
|
||||
targetEntity: string;
|
||||
targetId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface HistoryRecord {
|
||||
id: string;
|
||||
entity: string;
|
||||
entityId: string;
|
||||
before: Record<string, unknown>;
|
||||
after: Record<string, unknown>;
|
||||
actor: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CustomFieldDef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "text" | "long_text" | "number" | "date" | "boolean" | "single_select" | "multi_select" | "url" | "rating";
|
||||
options: string[];
|
||||
}
|
||||
|
||||
export interface Work {
|
||||
id: string;
|
||||
media_type: string;
|
||||
title: string;
|
||||
year: number | null;
|
||||
cover_url: string;
|
||||
creators: { name: string; role?: string }[];
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
id: string;
|
||||
workId: string;
|
||||
candidateWorkId: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Endpoints
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export interface EntryQuery {
|
||||
q?: string;
|
||||
mediaType?: string;
|
||||
status?: Status;
|
||||
draft?: boolean;
|
||||
parentId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request<User>("GET", "me"),
|
||||
login: (username: string, password: string) => request<User>("POST", "auth/login", { body: { username, password } }),
|
||||
register: (body: { username: string; password: string; displayName?: string; inviteCode?: string }) =>
|
||||
request<User>("POST", "auth/register", { body }),
|
||||
logout: () => request<void>("POST", "auth/logout"),
|
||||
|
||||
entries: (query: EntryQuery) => request<Page<EntryView>>("GET", "entries", { query: { ...query } }),
|
||||
entry: (id: string) => request<EntryView>("GET", `entries/${id}`),
|
||||
quickAdd: (title: string) => request<EntryView>("POST", "entries/quick", { body: { title } }),
|
||||
patchEntry: (id: string, patch: EntryPatch) => request<EntryView>("PATCH", `entries/${id}`, { body: patch }),
|
||||
moveEntry: (id: string, workId: string) => request<EntryView>("POST", `entries/${id}/move`, { body: { workId } }),
|
||||
history: (id: string) => request<HistoryRecord[]>("GET", `entries/${id}/history`),
|
||||
restore: (historyId: string) => request<void>("POST", `history/${historyId}/restore`),
|
||||
|
||||
engagements: (entryId: string) => request<Engagement[]>("GET", `entries/${entryId}/engagements`),
|
||||
addEngagement: (entryId: string, body: { startedAt?: PartialDate | null; finishedAt?: PartialDate | null }) =>
|
||||
request<Engagement>("POST", `entries/${entryId}/engagements`, { body }),
|
||||
deleteEngagement: (id: string) => request<void>("DELETE", `engagements/${id}`),
|
||||
|
||||
copies: (workId: string) => request<Copy[]>("GET", "copies", { query: { workId } }),
|
||||
addCopy: (body: Partial<Omit<Copy, "id">>) => request<Copy>("POST", "copies", { body }),
|
||||
|
||||
upload: (file: File, entityId: string) => {
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
form.set("entity", "entry");
|
||||
form.set("entityId", entityId);
|
||||
return request<Attachment>("POST", "attachments", { form });
|
||||
},
|
||||
|
||||
sources: () => request<Connection[]>("GET", "sources"),
|
||||
changes: () => request<Page<ChangeEvent>>("GET", "changes", { query: { limit: 200 } }),
|
||||
customFields: (mediaType: string) => request<CustomFieldDef[]>("GET", "custom-fields", { query: { mediaType, level: "entry" } }),
|
||||
|
||||
work: (id: string) => request<Work>("GET", `works/${id}`),
|
||||
searchWorks: (q: string, mediaType?: string) => request<Work[]>("GET", "works/search", { query: { q, mediaType, limit: 8 } }),
|
||||
suggestions: (workId: string) => request<Suggestion[]>("GET", `works/${workId}/suggestions`),
|
||||
acceptSuggestion: (id: string) => request<void>("POST", `suggestions/${id}/accept`),
|
||||
rejectSuggestion: (id: string) => request<void>("POST", `suggestions/${id}/reject`),
|
||||
};
|
||||
Reference in new issue
Block a user