Files
jules 3dbf2ac175 feat: auth scaffold, notifications inbox, resources CRUD, vitest baseline, typed API client
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>
2026-04-28 15:59:31 +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" }),
}