Files
arcadia-admin/app/lib/api.ts
jules f8cbf142b5 init: arcadia-admin — admin webapp for arcadia-core, cloned from vibespace
Initial commit. Spun up via the docs/STARTER.md recipe: cp from vibespace,
reset git, rename package, set brand to "Arcadia Admin" with Shield icon
in app/lib/identity.ts.

Inherits the full Crema sibling-lib wiring including @crema/arcadia-client
(typed HTTP + Phoenix Channels realtime against arcadia-core) and
@crema/arcadia-auth-ui (login/signup/password-reset/2FA forms). The /login
route already renders <LoginForm>; <ArcadiaProvider> in app/root.tsx reads
VITE_ARCADIA_URL (default localhost:4000) and VITE_ARCADIA_TENANT (default
"default").

CLAUDE.md and README rewritten to frame this as the admin app for
arcadia-core. docs/STARTER.md removed — arcadia-admin is a leaf consumer,
not a downstream starter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:28:39 +10:00

84 lines
2.6 KiB
TypeScript

// API — typed fetch wrapper. Auto-injects the session token, throws on
// non-2xx with a parsed error, and supports AbortSignal for cancellation.
//
// Replace `apiBaseURL` with your backend root. The Resources route shows the
// typical usage pattern.
import { loadSession, signOut } from "~/lib/session"
export const apiBaseURL = "/api"
export class ApiError extends Error {
status: number
body: unknown
constructor(message: string, status: number, body: unknown) {
super(message)
this.name = "ApiError"
this.status = status
this.body = body
}
}
export type ApiInit = Omit<RequestInit, "body" | "method"> & {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
body?: unknown
}
export async function apiFetch<T = unknown>(
path: string,
init: ApiInit = {},
): Promise<T> {
const session = loadSession()
const headers = new Headers(init.headers)
if (session?.token) headers.set("Authorization", `Bearer ${session.token}`)
if (init.body !== undefined && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json")
}
const url = path.startsWith("http") ? path : `${apiBaseURL}${path}`
const res = await fetch(url, {
...init,
method: init.method ?? "GET",
headers,
body:
init.body === undefined
? undefined
: typeof init.body === "string"
? init.body
: JSON.stringify(init.body),
})
if (res.status === 401) {
// Token rejected — clear session so the shell bounces to /login.
signOut()
}
const ct = res.headers.get("Content-Type") ?? ""
const parsed = ct.includes("application/json")
? await res.json().catch(() => null)
: await res.text().catch(() => null)
if (!res.ok) {
const message =
(parsed && typeof parsed === "object" && "message" in parsed
? String((parsed as { message: unknown }).message)
: null) ?? `${res.status} ${res.statusText}`
throw new ApiError(message, res.status, parsed)
}
return parsed as T
}
/** Convenience helpers. */
export const api = {
get: <T = unknown>(path: string, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "GET" }),
post: <T = unknown>(path: string, body?: unknown, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "POST", body }),
put: <T = unknown>(path: string, body?: unknown, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "PUT", body }),
patch: <T = unknown>(path: string, body?: unknown, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "PATCH", body }),
del: <T = unknown>(path: string, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "DELETE" }),
}