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>
This commit is contained in:
83
app/lib/api.ts
Normal file
83
app/lib/api.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// 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" }),
|
||||
}
|
||||
Reference in New Issue
Block a user