Files
crema-app-aifirst-template/app/components/layout/app-shell.tsx
jules 675f6f8b35 fix+upstream: hooks-order crash, ConfirmDialog/PageHeader/skeletons, styled error boundary
Hoists hooks above early return (render-time Navigate); ports finance's
ConfirmDialog/PageHeader/loading/states + APC error-copy mapper; removes
window.confirm, dead appbar search, and seeded fake notifications; wires toasts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:46:12 +10:00

577 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from "react"
const SIDEBAR_KEY = "crema.shell.sidebar"
import { NavLink, Navigate, useNavigate } from "react-router"
import {
Bell,
LayoutDashboard,
Boxes,
Activity,
Sparkles,
BookOpen,
Settings,
Search,
PanelLeftClose,
PanelLeftOpen,
User as UserIcon,
LogOut,
HelpCircle,
Menu,
Play,
// CREMA:NAV-ICONS
} from "lucide-react"
import {
useBrand,
useUser,
type Brand,
type User,
} from "~/lib/identity"
import {
Appbar,
AppbarActions,
AppbarSpacer,
AppbarTitle,
} from "~/components/layout/appbar"
import { ThemeToggle } from "~/components/layout/theme-toggle"
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,
unreadCount,
useNotifications,
} from "~/lib/notifications"
import { Button } from "~/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "~/components/ui/sheet"
import { ScriptsDialog, useScriptsHotkey } from "~/components/scripts-dialog"
type NavItem = {
to: string
icon: React.ComponentType<{ className?: string }>
label: string
end?: boolean
}
const navItems: NavItem[] = [
{ to: "/", icon: LayoutDashboard, label: "Overview", end: true },
{ to: "/resources", icon: Boxes, label: "Resources" },
{ to: "/activity", icon: Activity, label: "Activity" },
{ to: "/assistant", icon: Sparkles, label: "Assistant" },
{ to: "/library", icon: BookOpen, label: "Library" },
{ to: "/settings", icon: Settings, label: "Settings" },
// CREMA:NAV-ITEMS
]
type AppShellProps = {
title: string
children: React.ReactNode
brand?: Brand
user?: User
/**
* Optional theme name. When set, the shell wraps itself in
* `data-theme={theme}` so a route can opt into an alternate theme without
* the caller having to add an extra wrapping div.
*/
theme?: string
}
export function AppShell({
title,
children,
brand: brandOverride,
user: userOverride,
theme,
}: AppShellProps) {
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: session?.name || profile.name || defaultUser.name,
email: session?.email || profile.email || defaultUser.email,
initials: profileInitials(
session?.name || profile.name || defaultUser.name,
),
}
// All hooks must be called unconditionally — declare them BEFORE any
// early return so React's render-time hook count is stable. (Previously
// `if (!session) return null` sat above these hooks, so the hook count
// changed the moment the session flipped → hook-order crash.)
const [expanded, setExpanded] = useState<boolean>(() => {
if (typeof window === "undefined") return false
return localStorage.getItem(SIDEBAR_KEY) === "1"
})
useEffect(() => {
localStorage.setItem(SIDEBAR_KEY, expanded ? "1" : "0")
}, [expanded])
const [mobileOpen, setMobileOpen] = useState(false)
const [scriptsOpen, setScriptsOpen] = useState(false)
useScriptsHotkey(() => setScriptsOpen(true))
// Protected shell: redirect to /login when there's no session. Done at
// render time (not in an effect) so we don't briefly render a blank
// shell during the redirect window.
if (!session) {
const next =
typeof window !== "undefined"
? encodeURIComponent(window.location.pathname + window.location.search)
: ""
return <Navigate to={`/login?next=${next}`} replace />
}
const BrandIcon = brand.icon
return (
<div
data-theme={theme}
className="relative isolate flex min-h-svh"
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-2 focus:top-2 focus:z-50 focus:rounded-md focus:bg-primary focus:px-3 focus:py-2 focus:text-primary-foreground focus:shadow-lg"
>
Skip to main content
</a>
<aside
data-slot="sidebar"
data-expanded={expanded ? "true" : "false"}
className={[
"sticky top-0 z-30 hidden h-svh shrink-0 flex-col border-r bg-sidebar transition-[width] duration-base ease-standard md:flex",
expanded ? "w-60" : "w-16",
].join(" ")}
>
<div
className={[
"flex h-14 items-center gap-2 border-b px-3",
expanded ? "justify-between" : "justify-center",
].join(" ")}
>
{expanded ? (
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<BrandIcon className="size-4" />
</div>
<span className="font-heading font-semibold tracking-tight">
{brand.name}
</span>
</div>
) : (
<div className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<BrandIcon className="size-4" />
</div>
)}
</div>
<nav className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
{navItems.map((item) => {
const Icon = item.icon
return (
<NavLink
key={item.label}
to={item.to}
end={item.end}
title={expanded ? undefined : item.label}
data-action={`nav-${item.label.toLowerCase()}`}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors duration-fast ease-standard",
expanded ? "justify-start" : "justify-center",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
].join(" ")
}
>
<Icon className="size-5 shrink-0" />
{expanded && <span className="truncate">{item.label}</span>}
</NavLink>
)
})}
</nav>
<div className="shrink-0 border-t p-2">
<Button
data-action="sidebar-toggle"
variant="ghost"
size={expanded ? "sm" : "icon-sm"}
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse sidebar" : "Expand sidebar"}
className={expanded ? "w-full justify-start" : "w-full"}
>
{expanded ? (
<>
<PanelLeftClose className="size-4" />
<span>Collapse</span>
</>
) : (
<PanelLeftOpen className="size-4" />
)}
</Button>
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<Appbar className="sticky top-0 z-20 border-b">
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger
data-action="mobile-nav-toggle"
aria-label="Open navigation"
className="mr-1 inline-flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground md:hidden"
>
<Menu className="size-5" />
</SheetTrigger>
<SheetContent side="left" className="w-72 p-0">
<SheetHeader className="border-b">
<SheetTitle className="flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<BrandIcon className="size-4" />
</div>
{brand.name}
</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 p-2">
{navItems.map((item) => {
const Icon = item.icon
return (
<NavLink
key={item.label}
to={item.to}
end={item.end}
onClick={() => setMobileOpen(false)}
data-action={`nav-mobile-${item.label.toLowerCase()}`}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
].join(" ")
}
>
<Icon className="size-5 shrink-0" />
<span>{item.label}</span>
</NavLink>
)
})}
</nav>
</SheetContent>
</Sheet>
<AppbarTitle>{title}</AppbarTitle>
{/* Honest search affordance: the template has no search index, so
this routes to the Assistant — the app's real natural-language
surface — rather than being a dead <input>. Forks with a search
route should point this at it (and add a ⌘K palette hint). */}
<button
type="button"
data-action="appbar-search"
onClick={() => navigate("/assistant")}
className="ml-6 hidden h-9 w-80 items-center gap-2 rounded-md border bg-background px-2.5 text-left text-sm text-muted-foreground transition hover:bg-accent/40 md:flex"
title="Ask the assistant — it can answer questions and drive the app."
>
<Search className="size-4" />
<span className="flex-1 truncate">Ask the assistant</span>
</button>
<AppbarSpacer />
<AppbarActions>
<Button
data-action="appbar-scripts"
variant="ghost"
size="icon-sm"
aria-label="Run script (Cmd+Shift+P)"
title="Run script (Cmd+Shift+P)"
onClick={() => setScriptsOpen(true)}
>
<Play />
</Button>
<FontSizePicker />
<SurfacePicker />
<BackgroundPicker />
<ThemeToggle />
<NotificationsBell />
<DropdownMenu>
<DropdownMenuTrigger
data-action="appbar-avatar"
aria-label="Account menu"
className="rounded-full outline-none ring-offset-2 ring-offset-background transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring"
>
<Avatar className="size-8 cursor-pointer">
{profile.avatarUrl ? (
<AvatarImage src={profile.avatarUrl} alt={user.name} />
) : null}
<AvatarFallback>{user.initials}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel className="flex flex-col gap-0.5">
<span className="font-medium">{user.name}</span>
<span className="text-xs font-normal text-muted-foreground">
{user.email}
</span>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
data-action="avatar-profile"
onClick={() => navigate("/profile")}
>
<UserIcon /> Profile
</DropdownMenuItem>
<DropdownMenuItem
data-action="avatar-settings"
onClick={() => navigate("/settings")}
>
<Settings /> Settings
</DropdownMenuItem>
<DropdownMenuItem data-action="avatar-help">
<HelpCircle /> Help
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
data-action="avatar-signout"
variant="destructive"
onClick={() => {
signOut()
navigate("/login", { replace: true })
}}
>
<LogOut /> Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</AppbarActions>
</Appbar>
<div
id="main-content"
tabIndex={-1}
className="flex flex-1 flex-col gap-6 p-6 focus:outline-none"
>
{children}
</div>
</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()
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>
)
}