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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user