Auth - ~/lib/session.ts: Session type + loadSession/signIn/signOut/hasSession, reactive useSession hook (mock backend; replace fetch calls with your real auth endpoint when ready) - routes/login.tsx: form with email/password (mock-validated), bounces to ?next= on success - AppShell: redirects to /login when no session; account-menu Sign out now actually signs out; live session.name/email used for the appbar avatar (falls back to profile) Notifications - ~/lib/notifications.ts: persistent inbox with kinds (info/success/ warning/error), unreadCount, markRead, markAllRead, dismiss, dismissAll; seedIfEmpty for a friendly first-run - AppShell bell: 320px popover with badge, kind dots, per-row open (navigates to href) and dismiss; Mark all read + Clear actions - Hidden NotificationDispatcher in AppShell so the action bus can create real notifications via fill notif-title / notif-body / notif-kind / notif-href + click notif-create Data layer - ~/lib/api.ts: typed apiFetch<T> + api.get/post/put/patch/del, auto-attaches the session token, throws structured ApiError, signs out on 401 - ~/lib/resources.ts: example domain entity (CRUD) backed by localStorage today; each call is a 1:1 swap for api.get/post/put/del - routes/resources.tsx: real working table — search, add, inline status edit, delete; seeded demo rows on first load Tests - vitest + jsdom + @testing-library/react + @testing-library/jest-dom + vite-tsconfig-paths installed - vitest.config.ts (jsdom, globals, ~ aliases via tsconfig-paths) - vitest.setup.ts (RTL cleanup + localStorage clear between tests) - app/lib/session.test.ts and resources.test.ts as starter coverage - npm test / npm run test:watch scripts UI Control catalog - Login form, resources CRUD, notifications inbox, and the hidden notif-bridge ids tagged so the assistant can drive every new surface Threads - ThreadMessage now carries optional agentId so per-message authorship survives persona switches and handoffs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
84 lines
2.6 KiB
TypeScript
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" }),
|
|
}
|