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>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
const SIDEBAR_KEY = "crema.shell.sidebar"
|
||||
import { NavLink, useNavigate } from "react-router"
|
||||
@@ -39,7 +39,23 @@ import { BackgroundPicker } from "~/components/layout/background-picker"
|
||||
import { FontSizePicker } from "~/components/layout/font-size-picker"
|
||||
import { SurfacePicker } from "~/components/layout/surface-picker"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover"
|
||||
import { profileInitials, useProfile } from "~/lib/profile"
|
||||
import { signOut, useSession } from "~/lib/session"
|
||||
import {
|
||||
addNotification,
|
||||
dismiss,
|
||||
dismissAll,
|
||||
markAllRead,
|
||||
markRead,
|
||||
seedIfEmpty,
|
||||
unreadCount,
|
||||
useNotifications,
|
||||
} from "~/lib/notifications"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -100,12 +116,30 @@ export function AppShell({
|
||||
const defaultBrand = useBrand()
|
||||
const defaultUser = useUser()
|
||||
const profile = useProfile()
|
||||
const session = useSession()
|
||||
const navigate = useNavigate()
|
||||
const brand = brandOverride ?? defaultBrand
|
||||
// Prefer the live session for identity, fall back to the editable profile,
|
||||
// fall back to the stub user.
|
||||
const user = userOverride ?? {
|
||||
name: profile.name || defaultUser.name,
|
||||
email: profile.email || defaultUser.email,
|
||||
initials: profileInitials(profile.name || defaultUser.name),
|
||||
name: session?.name || profile.name || defaultUser.name,
|
||||
email: session?.email || profile.email || defaultUser.email,
|
||||
initials: profileInitials(
|
||||
session?.name || profile.name || defaultUser.name,
|
||||
),
|
||||
}
|
||||
|
||||
// Protected shell: bounce to /login when there's no session.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
if (!session) {
|
||||
const next = encodeURIComponent(
|
||||
window.location.pathname + window.location.search,
|
||||
)
|
||||
navigate(`/login?next=${next}`, { replace: true })
|
||||
}
|
||||
}, [session, navigate])
|
||||
if (!session) return null
|
||||
const [expanded, setExpanded] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false
|
||||
return localStorage.getItem(SIDEBAR_KEY) === "1"
|
||||
@@ -115,7 +149,6 @@ export function AppShell({
|
||||
}, [expanded])
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [scriptsOpen, setScriptsOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const BrandIcon = brand.icon
|
||||
|
||||
useScriptsHotkey(() => setScriptsOpen(true))
|
||||
@@ -280,14 +313,8 @@ export function AppShell({
|
||||
<SurfacePicker />
|
||||
<BackgroundPicker />
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
data-action="appbar-notifications"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell />
|
||||
</Button>
|
||||
<NotificationsBell />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
data-action="appbar-avatar"
|
||||
@@ -327,7 +354,14 @@ export function AppShell({
|
||||
<HelpCircle /> Help
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem data-action="avatar-signout" variant="destructive">
|
||||
<DropdownMenuItem
|
||||
data-action="avatar-signout"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
signOut()
|
||||
navigate("/login", { replace: true })
|
||||
}}
|
||||
>
|
||||
<LogOut /> Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -345,6 +379,195 @@ export function AppShell({
|
||||
</main>
|
||||
|
||||
<ScriptsDialog open={scriptsOpen} onOpenChange={setScriptsOpen} />
|
||||
<NotificationDispatcher />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationDispatcher() {
|
||||
// Hidden bridge so the action bus can create real notifications:
|
||||
// fill notify-title "Hello"
|
||||
// fill notify-body "Body text"
|
||||
// fill notify-kind "info" # info|success|warning|error
|
||||
// fill notify-href "/library" # optional
|
||||
// click notify-create
|
||||
const titleRef = useRef<HTMLInputElement>(null)
|
||||
const bodyRef = useRef<HTMLInputElement>(null)
|
||||
const kindRef = useRef<HTMLInputElement>(null)
|
||||
const hrefRef = useRef<HTMLInputElement>(null)
|
||||
const submit = () => {
|
||||
const title = titleRef.current?.value.trim() ?? ""
|
||||
if (!title) return
|
||||
const body = bodyRef.current?.value.trim() || undefined
|
||||
const rawKind = (kindRef.current?.value || "info").trim().toLowerCase()
|
||||
const kind = (
|
||||
["info", "success", "warning", "error"].includes(rawKind)
|
||||
? rawKind
|
||||
: "info"
|
||||
) as "info" | "success" | "warning" | "error"
|
||||
const href = hrefRef.current?.value.trim() || undefined
|
||||
addNotification({ title, body, kind, href })
|
||||
if (titleRef.current) titleRef.current.value = ""
|
||||
if (bodyRef.current) bodyRef.current.value = ""
|
||||
if (kindRef.current) kindRef.current.value = ""
|
||||
if (hrefRef.current) hrefRef.current.value = ""
|
||||
}
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed left-0 top-0 size-px overflow-hidden opacity-0"
|
||||
>
|
||||
<input
|
||||
ref={titleRef}
|
||||
data-action="notif-title"
|
||||
placeholder="title"
|
||||
aria-label="Notification title"
|
||||
/>
|
||||
<input
|
||||
ref={bodyRef}
|
||||
data-action="notif-body"
|
||||
placeholder="body"
|
||||
aria-label="Notification body"
|
||||
/>
|
||||
<input
|
||||
ref={kindRef}
|
||||
data-action="notif-kind"
|
||||
placeholder="info|success|warning|error"
|
||||
aria-label="Notification kind"
|
||||
/>
|
||||
<input
|
||||
ref={hrefRef}
|
||||
data-action="notif-href"
|
||||
placeholder="/path (optional)"
|
||||
aria-label="Notification href"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-action="notif-create"
|
||||
aria-label="Create notification"
|
||||
onClick={submit}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
create
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationsBell() {
|
||||
const items = useNotifications()
|
||||
const unread = unreadCount(items)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
seedIfEmpty()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
data-action="appbar-notifications"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<span className="relative inline-flex">
|
||||
<Bell />
|
||||
{unread > 0 && (
|
||||
<span className="absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold text-primary-foreground">
|
||||
{unread > 9 ? "9+" : unread}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-sm font-semibold">Notifications</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
data-action="notif-mark-all-read"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => markAllRead()}
|
||||
disabled={unread === 0}
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
<Button
|
||||
data-action="notif-clear"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => dismissAll()}
|
||||
disabled={items.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="max-h-80 overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<li className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No notifications.
|
||||
</li>
|
||||
) : (
|
||||
items.map((n) => (
|
||||
<li
|
||||
key={n.id}
|
||||
className={
|
||||
"group flex items-start gap-2 border-b px-3 py-2 text-sm transition-colors hover:bg-accent/40 " +
|
||||
(!n.readAt ? "bg-primary/5" : "")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"mt-1 size-2 shrink-0 rounded-full " +
|
||||
(n.kind === "success"
|
||||
? "bg-emerald-500"
|
||||
: n.kind === "warning"
|
||||
? "bg-amber-500"
|
||||
: n.kind === "error"
|
||||
? "bg-rose-500"
|
||||
: "bg-primary")
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-action={`notif-open-${n.id}`}
|
||||
onClick={() => {
|
||||
markRead(n.id)
|
||||
if (n.href) navigate(n.href)
|
||||
}}
|
||||
className="flex flex-1 flex-col items-start text-left"
|
||||
>
|
||||
<span className="font-medium">{n.title}</span>
|
||||
{n.body && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{n.body}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
{new Date(n.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action={`notif-dismiss-${n.id}`}
|
||||
onClick={() => dismiss(n.id)}
|
||||
aria-label="Dismiss"
|
||||
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-background hover:text-foreground group-hover:opacity-100"
|
||||
>
|
||||
<span aria-hidden>×</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
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" }),
|
||||
}
|
||||
155
app/lib/notifications.ts
Normal file
155
app/lib/notifications.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// Notifications — small reactive store for in-app toasts/inbox items.
|
||||
// Pair with @crema/notification-ui's <ToastProvider /> for transient toasts;
|
||||
// this store is for the appbar bell's persistent inbox.
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
|
||||
export type NotificationKind = "info" | "success" | "warning" | "error"
|
||||
|
||||
export type AppNotification = {
|
||||
id: string
|
||||
kind: NotificationKind
|
||||
title: string
|
||||
body?: string
|
||||
// Optional href to open when the row is clicked.
|
||||
href?: string
|
||||
createdAt: number
|
||||
readAt?: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "crema.notifications"
|
||||
const CHANGE_EVENT = "crema:notifications-change"
|
||||
const MAX_ITEMS = 200
|
||||
|
||||
function newId(): string {
|
||||
return `n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
function readFromStorage(): AppNotification[] {
|
||||
if (typeof window === "undefined") return []
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter(
|
||||
(n): n is AppNotification =>
|
||||
n &&
|
||||
typeof n.id === "string" &&
|
||||
typeof n.title === "string" &&
|
||||
typeof n.createdAt === "number" &&
|
||||
["info", "success", "warning", "error"].includes(n.kind),
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeToStorage(items: AppNotification[]) {
|
||||
if (typeof window === "undefined") return
|
||||
const trimmed = items.slice(0, MAX_ITEMS)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed))
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
} catch {
|
||||
/* quota — drop silently */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadNotifications(): AppNotification[] {
|
||||
return readFromStorage()
|
||||
}
|
||||
|
||||
export function addNotification(
|
||||
n: Omit<AppNotification, "id" | "createdAt">,
|
||||
): AppNotification {
|
||||
const next: AppNotification = {
|
||||
...n,
|
||||
id: newId(),
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
writeToStorage([next, ...readFromStorage()])
|
||||
return next
|
||||
}
|
||||
|
||||
export function markRead(id: string) {
|
||||
const items = readFromStorage().map((n) =>
|
||||
n.id === id ? { ...n, readAt: Date.now() } : n,
|
||||
)
|
||||
writeToStorage(items)
|
||||
}
|
||||
|
||||
export function markAllRead() {
|
||||
const now = Date.now()
|
||||
const items = readFromStorage().map((n) =>
|
||||
n.readAt ? n : { ...n, readAt: now },
|
||||
)
|
||||
writeToStorage(items)
|
||||
}
|
||||
|
||||
export function dismiss(id: string) {
|
||||
writeToStorage(readFromStorage().filter((n) => n.id !== id))
|
||||
}
|
||||
|
||||
export function dismissAll() {
|
||||
writeToStorage([])
|
||||
}
|
||||
|
||||
let cached: AppNotification[] | null = null
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
const onChange = () => {
|
||||
cached = null
|
||||
cb()
|
||||
}
|
||||
window.addEventListener(CHANGE_EVENT, onChange)
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === STORAGE_KEY) onChange()
|
||||
})
|
||||
return () => window.removeEventListener(CHANGE_EVENT, onChange)
|
||||
}
|
||||
function getSnapshot(): AppNotification[] {
|
||||
if (!cached) cached = readFromStorage()
|
||||
return cached
|
||||
}
|
||||
function getServerSnapshot(): AppNotification[] {
|
||||
return []
|
||||
}
|
||||
|
||||
export function useNotifications(): AppNotification[] {
|
||||
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
useEffect(() => {
|
||||
cached = null
|
||||
}, [])
|
||||
return value
|
||||
}
|
||||
|
||||
export function unreadCount(items: AppNotification[]): number {
|
||||
return items.filter((n) => !n.readAt).length
|
||||
}
|
||||
|
||||
/** Seed a few demo notifications on first load so the bell isn't empty. */
|
||||
export function seedIfEmpty() {
|
||||
if (typeof window === "undefined") return
|
||||
if (localStorage.getItem(STORAGE_KEY)) return
|
||||
const now = Date.now()
|
||||
const seed: AppNotification[] = [
|
||||
{
|
||||
id: newId(),
|
||||
kind: "info",
|
||||
title: "Welcome",
|
||||
body: "Tag elements with data-action and the assistant can drive them.",
|
||||
href: "/assistant",
|
||||
createdAt: now - 60_000,
|
||||
},
|
||||
{
|
||||
id: newId(),
|
||||
kind: "success",
|
||||
title: "Profile saved",
|
||||
body: "Your display name and avatar are live across the app.",
|
||||
href: "/profile",
|
||||
createdAt: now - 5 * 60_000,
|
||||
},
|
||||
]
|
||||
writeToStorage(seed)
|
||||
}
|
||||
32
app/lib/resources.test.ts
Normal file
32
app/lib/resources.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest"
|
||||
|
||||
import {
|
||||
createResource,
|
||||
deleteResource,
|
||||
listResources,
|
||||
updateResource,
|
||||
} from "./resources"
|
||||
|
||||
describe("resources", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it("creates, updates, and deletes", () => {
|
||||
expect(listResources()).toEqual([])
|
||||
const r = createResource({ name: "Test", owner: "Atlas" })
|
||||
expect(r.status).toBe("active")
|
||||
expect(listResources()).toHaveLength(1)
|
||||
|
||||
const updated = updateResource(r.id, { status: "paused" })
|
||||
expect(updated?.status).toBe("paused")
|
||||
expect(updated?.updatedAt).toBeGreaterThanOrEqual(r.updatedAt)
|
||||
|
||||
deleteResource(r.id)
|
||||
expect(listResources()).toEqual([])
|
||||
})
|
||||
|
||||
it("ignores updates for unknown ids", () => {
|
||||
expect(updateResource("missing", { name: "x" })).toBeNull()
|
||||
})
|
||||
})
|
||||
157
app/lib/resources.ts
Normal file
157
app/lib/resources.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
// Resource store — example domain entity.
|
||||
// Backed by localStorage today, but written so each call is a single function
|
||||
// you can swap with `api.get/post/put/del` once you have a real backend.
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
|
||||
export type Resource = {
|
||||
id: string
|
||||
name: string
|
||||
status: "active" | "paused" | "archived"
|
||||
owner: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "crema.resources"
|
||||
const CHANGE_EVENT = "crema:resources-change"
|
||||
|
||||
function newId() {
|
||||
return `r-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
function readFromStorage(): Resource[] {
|
||||
if (typeof window === "undefined") return []
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter(
|
||||
(r): r is Resource =>
|
||||
r &&
|
||||
typeof r.id === "string" &&
|
||||
typeof r.name === "string" &&
|
||||
["active", "paused", "archived"].includes(r.status) &&
|
||||
typeof r.owner === "string" &&
|
||||
typeof r.createdAt === "number" &&
|
||||
typeof r.updatedAt === "number",
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function write(items: Resource[]) {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD — these mirror what `api.get/post/put/del` would look like.
|
||||
export function listResources(): Resource[] {
|
||||
return readFromStorage()
|
||||
}
|
||||
|
||||
export function createResource(input: {
|
||||
name: string
|
||||
owner: string
|
||||
status?: Resource["status"]
|
||||
}): Resource {
|
||||
const now = Date.now()
|
||||
const r: Resource = {
|
||||
id: newId(),
|
||||
name: input.name,
|
||||
owner: input.owner,
|
||||
status: input.status ?? "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
write([r, ...readFromStorage()])
|
||||
return r
|
||||
}
|
||||
|
||||
export function updateResource(
|
||||
id: string,
|
||||
patch: Partial<Omit<Resource, "id" | "createdAt">>,
|
||||
): Resource | null {
|
||||
const items = readFromStorage()
|
||||
let updated: Resource | null = null
|
||||
const next = items.map((r) => {
|
||||
if (r.id !== id) return r
|
||||
updated = { ...r, ...patch, updatedAt: Date.now() }
|
||||
return updated
|
||||
})
|
||||
if (updated) write(next)
|
||||
return updated
|
||||
}
|
||||
|
||||
export function deleteResource(id: string) {
|
||||
write(readFromStorage().filter((r) => r.id !== id))
|
||||
}
|
||||
|
||||
let cached: Resource[] | null = null
|
||||
function subscribe(cb: () => void) {
|
||||
const onChange = () => {
|
||||
cached = null
|
||||
cb()
|
||||
}
|
||||
window.addEventListener(CHANGE_EVENT, onChange)
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === STORAGE_KEY) onChange()
|
||||
})
|
||||
return () => window.removeEventListener(CHANGE_EVENT, onChange)
|
||||
}
|
||||
function getSnapshot(): Resource[] {
|
||||
if (!cached) cached = readFromStorage()
|
||||
return cached
|
||||
}
|
||||
function getServerSnapshot(): Resource[] {
|
||||
return []
|
||||
}
|
||||
|
||||
export function useResources(): Resource[] {
|
||||
const v = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
useEffect(() => {
|
||||
cached = null
|
||||
}, [])
|
||||
return v
|
||||
}
|
||||
|
||||
/** Seed a few rows on first load so the table isn't empty. */
|
||||
export function seedResourcesIfEmpty() {
|
||||
if (typeof window === "undefined") return
|
||||
if (localStorage.getItem(STORAGE_KEY)) return
|
||||
const now = Date.now()
|
||||
const seed: Resource[] = [
|
||||
{
|
||||
id: newId(),
|
||||
name: "Acme dashboard",
|
||||
status: "active",
|
||||
owner: "Atlas",
|
||||
createdAt: now - 86_400_000 * 3,
|
||||
updatedAt: now - 3600_000,
|
||||
},
|
||||
{
|
||||
id: newId(),
|
||||
name: "Onboarding pipeline",
|
||||
status: "paused",
|
||||
owner: "Forge",
|
||||
createdAt: now - 86_400_000 * 7,
|
||||
updatedAt: now - 86_400_000,
|
||||
},
|
||||
{
|
||||
id: newId(),
|
||||
name: "Q1 report draft",
|
||||
status: "archived",
|
||||
owner: "Inkwell",
|
||||
createdAt: now - 86_400_000 * 30,
|
||||
updatedAt: now - 86_400_000 * 14,
|
||||
},
|
||||
]
|
||||
write(seed)
|
||||
}
|
||||
31
app/lib/session.test.ts
Normal file
31
app/lib/session.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest"
|
||||
|
||||
import { hasSession, loadSession, signIn, signOut } from "./session"
|
||||
|
||||
describe("session", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it("starts unauthenticated", () => {
|
||||
expect(loadSession()).toBeNull()
|
||||
expect(hasSession()).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects empty credentials", async () => {
|
||||
await expect(signIn("", "")).rejects.toThrow(/required/i)
|
||||
await expect(signIn("not-an-email", "pw")).rejects.toThrow(/valid email/i)
|
||||
expect(hasSession()).toBe(false)
|
||||
})
|
||||
|
||||
it("creates a session on sign-in and clears on sign-out", async () => {
|
||||
const session = await signIn("alice@example.com", "hunter2")
|
||||
expect(session.email).toBe("alice@example.com")
|
||||
expect(session.token).toMatch(/^dev-/)
|
||||
expect(hasSession()).toBe(true)
|
||||
|
||||
signOut()
|
||||
expect(loadSession()).toBeNull()
|
||||
expect(hasSession()).toBe(false)
|
||||
})
|
||||
})
|
||||
129
app/lib/session.ts
Normal file
129
app/lib/session.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// Session — minimal auth scaffold backed by localStorage.
|
||||
// Swap loadSession/signIn/signOut for real calls (cookies + server) when you
|
||||
// wire a backend. The shape here matches what AppShell + useUser expect.
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
|
||||
import { profileInitials } from "~/lib/profile"
|
||||
|
||||
export type Session = {
|
||||
userId: string
|
||||
name: string
|
||||
email: string
|
||||
token: string
|
||||
// Issued at, ms since epoch.
|
||||
issuedAt: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "crema.session"
|
||||
const CHANGE_EVENT = "crema:session-change"
|
||||
|
||||
function readFromStorage(): Session | null {
|
||||
if (typeof window === "undefined") return null
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Partial<Session>
|
||||
if (
|
||||
typeof parsed.userId !== "string" ||
|
||||
typeof parsed.email !== "string" ||
|
||||
typeof parsed.token !== "string"
|
||||
)
|
||||
return null
|
||||
return {
|
||||
userId: parsed.userId,
|
||||
name:
|
||||
typeof parsed.name === "string" && parsed.name.trim()
|
||||
? parsed.name
|
||||
: parsed.email,
|
||||
email: parsed.email,
|
||||
token: parsed.token,
|
||||
issuedAt:
|
||||
typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSession(): Session | null {
|
||||
return readFromStorage()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock sign-in. Validates only that email + password are non-empty; returns
|
||||
* a fake session. Replace with a real fetch to your auth endpoint.
|
||||
*/
|
||||
export async function signIn(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<Session> {
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
if (!email.trim() || !password.trim()) {
|
||||
throw new Error("Email and password are required.")
|
||||
}
|
||||
if (!email.includes("@")) {
|
||||
throw new Error("Enter a valid email address.")
|
||||
}
|
||||
const session: Session = {
|
||||
userId: `u-${Date.now().toString(36)}`,
|
||||
name: email.split("@")[0].replace(/\W/g, " ").trim() || email,
|
||||
email,
|
||||
token: `dev-${Math.random().toString(36).slice(2, 14)}`,
|
||||
issuedAt: Date.now(),
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
export function signOut() {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
}
|
||||
|
||||
/** True if a non-expired session is in storage. */
|
||||
export function hasSession(): boolean {
|
||||
return !!readFromStorage()
|
||||
}
|
||||
|
||||
let cached: Session | null = null
|
||||
let cacheValid = false
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
const onChange = () => {
|
||||
cacheValid = false
|
||||
cb()
|
||||
}
|
||||
window.addEventListener(CHANGE_EVENT, onChange)
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === STORAGE_KEY) onChange()
|
||||
})
|
||||
return () => window.removeEventListener(CHANGE_EVENT, onChange)
|
||||
}
|
||||
function getSnapshot(): Session | null {
|
||||
if (!cacheValid) {
|
||||
cached = readFromStorage()
|
||||
cacheValid = true
|
||||
}
|
||||
return cached
|
||||
}
|
||||
function getServerSnapshot(): Session | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function useSession(): Session | null {
|
||||
const s = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
useEffect(() => {
|
||||
cacheValid = false
|
||||
}, [])
|
||||
return s
|
||||
}
|
||||
|
||||
export function sessionInitials(session: Session | null): string {
|
||||
if (!session) return "?"
|
||||
return profileInitials(session.name || session.email)
|
||||
}
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
|
||||
export type ThreadMessage = { role: "user" | "assistant"; content: string }
|
||||
export type ThreadMessage = {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
/** Persona that authored this assistant message (omitted for user msgs). */
|
||||
agentId?: string
|
||||
}
|
||||
|
||||
export type Thread = {
|
||||
id: string
|
||||
|
||||
@@ -8,5 +8,6 @@ export default [
|
||||
route("library", "routes/library.tsx"),
|
||||
route("settings", "routes/settings.tsx"),
|
||||
route("profile", "routes/profile.tsx"),
|
||||
route("login", "routes/login.tsx"),
|
||||
// CREMA:ROUTES
|
||||
] satisfies RouteConfig
|
||||
|
||||
@@ -53,6 +53,16 @@ Agents settings: settings-agent-new, settings-agent-reset, settings-agent-activa
|
||||
Assistant agent picker: assistant-agent (dropdown — click to switch persona)
|
||||
Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-thread-new, assistant-thread-switch-<id>, assistant-thread-rename-<id>, assistant-thread-delete-<id>, assistant-ui-control, assistant-compact, assistant-restore-compact, assistant-regenerate, assistant-continue, assistant-show-prompt, assistant-copy-md, assistant-export-md, assistant-save-library, assistant-compare, assistant-handoff-<id>, assistant-stop, assistant-clear, assistant-retry-probe, assistant-actions (open the kebab popover first to reveal the rest), assistant-voice (mic), assistant-msg-pin-<i>, assistant-msg-edit-<i>, assistant-msg-speak-<i>
|
||||
Library page: library-search, library-open-<id>, library-copy-<id>, library-download-<id>, library-delete-<id>
|
||||
Resources page: resources-search, resources-new-name, resources-create, resources-status-<id>, resources-delete-<id>
|
||||
Login page: login-email, login-password, login-submit
|
||||
Notifications popover: appbar-notifications (open), notif-mark-all-read, notif-clear, notif-open-<id>, notif-dismiss-<id>
|
||||
Create a notification (hidden bridge — always available, even when not visible): fill the four hidden inputs, then click the submit button. Recipe:
|
||||
fill notif-title "Reminder"
|
||||
fill notif-body "Take a 5-minute break"
|
||||
fill notif-kind "info" # info | success | warning | error
|
||||
fill notif-href "/library" # optional, omit if no link
|
||||
click notif-create
|
||||
Use this any time the user asks you to remind them, leave a note, flag something, or queue an item — drop a notification.
|
||||
|
||||
Example — User: "Go to settings and set the response cap to 1024" →
|
||||
"On it.
|
||||
@@ -391,11 +401,18 @@ function AssistantSurface({
|
||||
})
|
||||
|
||||
// Persist conversation back into the active thread.
|
||||
// Preserve any agentId already stamped on prior messages, and stamp newly
|
||||
// appended assistant messages with the *currently active* agent.
|
||||
useEffect(() => {
|
||||
if (isStreaming) return
|
||||
const stamped: ThreadMessage[] = messages.map((m, i) => {
|
||||
const prior = thread.messages[i]
|
||||
if (m.role === "user") return { role: "user", content: m.content }
|
||||
const agentId = prior?.agentId ?? activeAgentId
|
||||
return { role: "assistant", content: m.content, agentId }
|
||||
})
|
||||
updateThread(thread.id, {
|
||||
messages: messages as ThreadMessage[],
|
||||
// Auto-title from the first user message if the thread is still untitled.
|
||||
messages: stamped,
|
||||
...(thread.title === "New conversation" &&
|
||||
messages[0]?.role === "user"
|
||||
? { title: deriveTitleFromFirstMessage(messages[0].content) }
|
||||
@@ -667,9 +684,20 @@ function AssistantSurface({
|
||||
{ maxTokens: 220 },
|
||||
)
|
||||
const note = `🤝 **Handoff: ${activeAgent.name} → ${target.name}**\n\n${briefing.trim()}`
|
||||
// Stamp the existing thread messages (preserve their authorship) and
|
||||
// attribute the handoff note itself to the OUTGOING agent.
|
||||
const stamped: ThreadMessage[] = messages.map((m, i) => {
|
||||
const prior = thread.messages[i]
|
||||
if (m.role === "user") return { role: "user", content: m.content }
|
||||
return {
|
||||
role: "assistant",
|
||||
content: m.content,
|
||||
agentId: prior?.agentId ?? activeAgent.id,
|
||||
}
|
||||
})
|
||||
const next: ThreadMessage[] = [
|
||||
...(messages as ThreadMessage[]),
|
||||
{ role: "assistant", content: note },
|
||||
...stamped,
|
||||
{ role: "assistant", content: note, agentId: activeAgent.id },
|
||||
]
|
||||
updateThread(thread.id, { messages: next, agentId: target.id })
|
||||
saveActiveAgentId(target.id)
|
||||
@@ -832,14 +860,37 @@ function AssistantSurface({
|
||||
!messages.slice(i + 1).some((x) => x.role === "user")
|
||||
const isEditing = editingIndex === i
|
||||
const isUser = m.role === "user"
|
||||
const msgAgentId =
|
||||
!isUser
|
||||
? thread.messages[i]?.agentId ?? activeAgent?.id
|
||||
: undefined
|
||||
const msgAgent = msgAgentId
|
||||
? agents.find((a) => a.id === msgAgentId) ?? activeAgent
|
||||
: undefined
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={
|
||||
"group flex flex-col " +
|
||||
(isUser ? "items-end" : "items-start")
|
||||
"group flex w-full items-start gap-2 " +
|
||||
(isUser ? "flex-row-reverse" : "flex-row")
|
||||
}
|
||||
>
|
||||
{!isUser && msgAgent && (
|
||||
<Avatar
|
||||
className="mt-5 size-7 shrink-0 ring-1 ring-border"
|
||||
title={`${msgAgent.name} — ${msgAgent.role}`}
|
||||
>
|
||||
<AvatarFallback
|
||||
style={{
|
||||
background: agentTint(msgAgent.id),
|
||||
color: "var(--primary-foreground)",
|
||||
}}
|
||||
className="text-[11px] font-semibold"
|
||||
>
|
||||
{agentInitials(msgAgent.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
"flex max-w-[80%] flex-col " +
|
||||
@@ -848,8 +899,15 @@ function AssistantSurface({
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1.5 px-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{isUser ? "You" : "Assistant"}
|
||||
{isUser
|
||||
? "You"
|
||||
: (msgAgent?.name ?? "Assistant")}
|
||||
</span>
|
||||
{!isUser && msgAgent && (
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
· {msgAgent.role}
|
||||
</span>
|
||||
)}
|
||||
{isPinned && (
|
||||
<Pin className="size-3 fill-primary text-primary" />
|
||||
)}
|
||||
@@ -1368,10 +1426,24 @@ function AssistantSurface({
|
||||
responseBudget={responseBudget}
|
||||
onClose={() => setCompareOpen(false)}
|
||||
onAppend={(agentName, content) => {
|
||||
const speaker = agents.find((a) => a.name === agentName)
|
||||
const note = `🪞 **${agentName} says:**\n\n${content}`
|
||||
const stamped: ThreadMessage[] = messages.map((m, i) => {
|
||||
const prior = thread.messages[i]
|
||||
if (m.role === "user") return { role: "user", content: m.content }
|
||||
return {
|
||||
role: "assistant",
|
||||
content: m.content,
|
||||
agentId: prior?.agentId ?? activeAgent?.id,
|
||||
}
|
||||
})
|
||||
const next: ThreadMessage[] = [
|
||||
...(messages as ThreadMessage[]),
|
||||
{ role: "assistant", content: note },
|
||||
...stamped,
|
||||
{
|
||||
role: "assistant",
|
||||
content: note,
|
||||
agentId: speaker?.id ?? activeAgent?.id,
|
||||
},
|
||||
]
|
||||
updateThread(thread.id, { messages: next })
|
||||
setActionLog(`Appended ${agentName}'s reply to the thread.`)
|
||||
|
||||
117
app/routes/login.tsx
Normal file
117
app/routes/login.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useNavigate, useSearchParams } from "react-router"
|
||||
import { Loader2, LogIn, Sparkles } from "lucide-react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { useBrand } from "~/lib/identity"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { signIn, useSession } from "~/lib/session"
|
||||
|
||||
export const meta = () => pageTitle("Sign in")
|
||||
|
||||
export default function LoginRoute() {
|
||||
const navigate = useNavigate()
|
||||
const [params] = useSearchParams()
|
||||
const session = useSession()
|
||||
const brand = useBrand()
|
||||
const BrandIcon = brand.icon
|
||||
|
||||
const next = params.get("next") || "/"
|
||||
const [email, setEmail] = useState("you@example.com")
|
||||
const [password, setPassword] = useState("hunter2")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Already signed in? Bounce.
|
||||
useEffect(() => {
|
||||
if (session) navigate(next, { replace: true })
|
||||
}, [session, next, navigate])
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await signIn(email, password)
|
||||
navigate(next, { replace: true })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Sign-in failed.")
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative isolate flex min-h-svh items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="items-center text-center">
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-xl bg-primary text-primary-foreground">
|
||||
<BrandIcon className="size-5" />
|
||||
</div>
|
||||
<CardTitle>Sign in to {brand.name}</CardTitle>
|
||||
<CardDescription>
|
||||
Mock auth — any email + non-empty password works. Wire{" "}
|
||||
<code className="font-mono text-xs">~/lib/session.ts</code> to your
|
||||
real backend.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Email</span>
|
||||
<Input
|
||||
data-action="login-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Password</span>
|
||||
<Input
|
||||
data-action="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p className="rounded-md border border-destructive/50 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
data-action="login-submit"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="mt-1"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="size-4" />
|
||||
)}
|
||||
Sign in
|
||||
</Button>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground">
|
||||
<Sparkles className="mr-1 inline size-3" />
|
||||
No account needed in dev — credentials aren't checked.
|
||||
</p>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Boxes } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Plus, Search, Trash2 } from "lucide-react"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -8,35 +10,172 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import {
|
||||
createResource,
|
||||
deleteResource,
|
||||
seedResourcesIfEmpty,
|
||||
updateResource,
|
||||
useResources,
|
||||
type Resource,
|
||||
} from "~/lib/resources"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
|
||||
export const meta = () => pageTitle("Resources")
|
||||
|
||||
const statuses: Resource["status"][] = ["active", "paused", "archived"]
|
||||
|
||||
export default function ResourcesRoute() {
|
||||
const items = useResources()
|
||||
const [query, setQuery] = useState("")
|
||||
const [draftName, setDraftName] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
seedResourcesIfEmpty()
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return q
|
||||
? items.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
r.owner.toLowerCase().includes(q) ||
|
||||
r.status.includes(q),
|
||||
)
|
||||
: items
|
||||
}, [items, query])
|
||||
|
||||
const create = () => {
|
||||
const name = draftName.trim()
|
||||
if (!name) return
|
||||
createResource({ name, owner: "You" })
|
||||
setDraftName("")
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Resources">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resources</CardTitle>
|
||||
<CardDescription>
|
||||
A list/detail surface for the entities your app manages.
|
||||
Example domain entity. CRUD goes through{" "}
|
||||
<code className="font-mono text-xs">~/lib/resources.ts</code> —
|
||||
swap that file's calls for{" "}
|
||||
<code className="font-mono text-xs">api.get/post/put/del</code>{" "}
|
||||
from <code className="font-mono text-xs">~/lib/api.ts</code> when
|
||||
you have a backend.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/30 p-12 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
|
||||
<Boxes className="size-6" />
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-48">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
data-action="resources-search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search name, owner, status…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<p className="font-medium">No resources yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
This route is the canonical "traditional" surface. Drop in{" "}
|
||||
<code className="font-mono text-xs">@crema/table-ui</code> or{" "}
|
||||
<code className="font-mono text-xs">@crema/data-ui</code> when
|
||||
you have data to show.
|
||||
<Input
|
||||
data-action="resources-new-name"
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") create()
|
||||
}}
|
||||
placeholder="New resource name…"
|
||||
className="max-w-64"
|
||||
/>
|
||||
<Button
|
||||
data-action="resources-create"
|
||||
onClick={create}
|
||||
disabled={!draftName.trim()}
|
||||
>
|
||||
<Plus className="size-4" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border bg-card/40">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">Name</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Owner</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Updated</th>
|
||||
<th className="w-10 px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-3 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{items.length === 0
|
||||
? "No resources yet — add one above."
|
||||
: "No matches."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className="border-t transition-colors hover:bg-accent/30"
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{r.name}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{r.owner}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
data-action={`resources-status-${r.id}`}
|
||||
value={r.status}
|
||||
onChange={(e) =>
|
||||
updateResource(r.id, {
|
||||
status: e.target.value as Resource["status"],
|
||||
})
|
||||
}
|
||||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{statuses.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
|
||||
{new Date(r.updatedAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<Button
|
||||
data-action={`resources-delete-${r.id}`}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete "${r.name}"?`))
|
||||
deleteResource(r.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{items.length} total · {filtered.length} shown
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AppShell>
|
||||
|
||||
1087
package-lock.json
generated
1087
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@
|
||||
"dev": "react-router dev",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"sync-libs": "node scripts/sync-libs.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -33,15 +35,19 @@
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "7.13.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@types/topojson-specification": "^1.0.5",
|
||||
"jsdom": "^29.1.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
13
vitest.config.ts
Normal file
13
vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
import tsconfigPaths from "vite-tsconfig-paths"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
include: ["app/**/*.{test,spec}.{ts,tsx}"],
|
||||
css: false,
|
||||
},
|
||||
})
|
||||
8
vitest.setup.ts
Normal file
8
vitest.setup.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest"
|
||||
import { afterEach } from "vitest"
|
||||
import { cleanup } from "@testing-library/react"
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
if (typeof localStorage !== "undefined") localStorage.clear()
|
||||
})
|
||||
Reference in New Issue
Block a user