Files
arcadia-admin/app/components/layout/app-shell.tsx
jules 7415b40240 Admin UX overhaul: P0 fixes, error-UX foundations, nav cleanup, tenant detail page
From the 2026-07-14 UI/UX audit (17/40). Four phases:

P1 — Settings route crashed on Agents→Edit (Input/Textarea used but never
imported). Added imports + a shared route-level error boundary
(components/route-error.tsx) re-exported from all shell routes, so one
crashing panel degrades to an explained card with the nav intact instead of
replacing the whole app with a stack trace. Corrected the stale CLAUDE.md
claim that `npm run typecheck` crashes — it works, and would have caught the
missing import.

P2 — Error/empty/feedback foundations. Fixed useSession identity churn that
fired ~3x duplicate fetches per screen and self-inflicted 429s (referentially
stable snapshot). New lib/errors.ts (describeError → plain-language + the fix)
and components/data-state.tsx (DataState renders exactly one of
error/loading/empty/content, so a failed load never shows as "empty";
DialogError for in-dialog failures; 429 auto-retry). Rolled across all 15
list routes; every mutation now toasts. Surfaced+fixed two silent-failure
bugs (sso + buckets-CORS swallowed load errors; the latter could wipe rules
on save).

P3 — Nav IA + trust cleanup. Default-expanded rail; regrouped into 7 coherent
sections; collapsed the Apps/Plan/Entitlements stub triplication into one
honest /billing page; deleted fabricated seeded notifications and the dead
Help menu item; fixed the 403 copy (referenced a tenant switcher that doesn't
exist); removed orphan /assistant + /library routes; gated dev-seed login
hints behind DEV; renamed /activity → /audit-log with a redirect.

P4 — Tenant detail page (routes/tenants.$id.tsx + components/tenant-detail/*),
closing the provision→configure gap. 8 tabs (Overview, Plan & quotas,
Branding, Localization, Email & SMS, Feature flags, IP rules, Inbound
webhooks), each verified saving to the real backend. Row name links to detail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:43:57 +10:00

903 lines
30 KiB
TypeScript
Raw 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, useMemo, useRef, useState } from "react"
const SIDEBAR_KEY = "crema.shell.sidebar"
const NAV_GROUPS_KEY = "crema.shell.nav-groups"
import { NavLink, useLocation, useNavigate } from "react-router"
import {
Bell,
LayoutDashboard,
Boxes,
Activity,
Building2,
Sparkles,
Bot,
BookOpen,
Settings,
Search,
PanelLeftClose,
PanelLeftOpen,
User as UserIcon,
LogOut,
Menu,
Play,
HardDrive,
Users as UsersIcon,
KeyRound,
Webhook as WebhookIcon,
CalendarClock,
Gauge,
UserCheck,
Network,
Building,
ShieldCheck,
Megaphone,
AlertOctagon,
SearchCode,
ChevronDown,
Database,
Plug,
MessageSquare,
CreditCard,
// 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 { capabilityForPath, useCapabilities } from "~/lib/capabilities"
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 { Input } from "~/components/ui/input"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "~/components/ui/sheet"
import { ScriptsDialog, useScriptsHotkey } from "~/components/scripts-dialog"
import { RouteGuard } from "~/components/route-guard"
type NavItem = {
to: string
icon: React.ComponentType<{ className?: string }>
label: string
end?: boolean
}
type NavGroup = {
key: string
label: string
icon: React.ComponentType<{ className?: string }>
items: NavItem[]
}
// Pinned items render flat at the top of the rail, above any groups.
const pinnedTop: NavItem[] = [
{ to: "/", icon: LayoutDashboard, label: "Overview", end: true },
]
// Pinned items render flat at the bottom of the rail, below all groups.
// Audit log is cross-cutting rather than owned by any one group, so it sits
// here next to Settings.
const pinnedBottom: NavItem[] = [
{ to: "/audit-log", icon: Activity, label: "Audit log" },
{ to: "/settings", icon: Settings, label: "Settings" },
]
const navGroups: NavGroup[] = [
{
key: "tenancy",
label: "Tenancy",
icon: Building2,
items: [
{ to: "/tenants", icon: Building2, label: "Tenants" },
{ to: "/organizations", icon: Building, label: "Organizations" },
{ to: "/users", icon: UsersIcon, label: "Users" },
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
],
},
{
key: "billing",
label: "Billing",
icon: CreditCard,
// One item today (Plan/Entitlements/Apps collapsed here — none has a live
// endpoint yet). They split back into siblings under this group once wired.
items: [{ to: "/billing", icon: CreditCard, label: "Plan & usage" }],
},
{
key: "data",
label: "Data",
icon: Database,
items: [
{ to: "/storage", icon: HardDrive, label: "Storage" },
{ to: "/buckets", icon: Boxes, label: "Buckets" },
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
],
},
{
key: "automation",
label: "Automation",
icon: Plug,
items: [
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
{ to: "/integrations", icon: Plug, label: "Integrations" },
],
},
{
key: "infrastructure",
label: "Infrastructure",
icon: Network,
items: [
{ to: "/networking", icon: Network, label: "Networking" },
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
],
},
{
key: "comms",
label: "Communications",
icon: MessageSquare,
items: [
{ to: "/announcements", icon: Megaphone, label: "Announcements" },
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
],
},
{
key: "ai",
label: "AI & Search",
icon: Sparkles,
items: [
{ to: "/ai", icon: Bot, label: "AI" },
{ to: "/search", icon: SearchCode, label: "Search" },
],
},
]
// Items appended by `crema add <lib>` land here. Rendered ungrouped at
// the bottom of the groups, above the pinned footer.
const extraNavItems: NavItem[] = [
// CREMA:NAV-ITEMS
]
function readNavGroupState(): Record<string, boolean> {
if (typeof window === "undefined") return {}
try {
const raw = localStorage.getItem(NAV_GROUPS_KEY)
return raw ? (JSON.parse(raw) as Record<string, boolean>) : {}
} catch {
return {}
}
}
type AppShellProps = {
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({
children,
brand: brandOverride,
user: userOverride,
theme,
}: AppShellProps) {
const defaultBrand = useBrand()
const defaultUser = useUser()
const profile = useProfile()
const session = useSession()
const caps = useCapabilities()
const navigate = useNavigate()
const brand = brandOverride ?? defaultBrand
// Prefer the live session for identity, fall back to the stub user.
const user = userOverride ?? {
name: session?.name || defaultUser.name,
email: session?.email || defaultUser.email,
initials: profileInitials(session?.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])
// All hooks must run unconditionally — keep them above the session
// short-circuit so a sign-out doesn't reduce the hook count and trip
// React's "rendered fewer hooks than expected" check.
const [expanded, setExpanded] = useState<boolean>(() => {
if (typeof window === "undefined") return true
// Default to expanded on first run — an icon-only rail of ~18 pictograms is
// unreadable to anyone who hasn't memorised it. Collapse stays available and
// is remembered once chosen.
const stored = localStorage.getItem(SIDEBAR_KEY)
return stored === null ? true : stored === "1"
})
useEffect(() => {
localStorage.setItem(SIDEBAR_KEY, expanded ? "1" : "0")
}, [expanded])
const [mobileOpen, setMobileOpen] = useState(false)
const [scriptsOpen, setScriptsOpen] = useState(false)
useScriptsHotkey(() => setScriptsOpen(true))
const location = useLocation()
// Filter the nav by what the active session can actually reach. A
// capability map exists for every protected route — items without one
// (or whose capability isn't held) are dropped here, so the sidebar
// doesn't advertise routes the user will only hit a 403 from.
const allowed = (item: NavItem): boolean => {
const cap = capabilityForPath(item.to)
if (!cap) return true // unknown routes default to visible
return caps.has(cap)
}
const visiblePinnedTop = useMemo(
() => pinnedTop.filter(allowed),
[caps],
)
const visiblePinnedBottom = useMemo(
() => pinnedBottom.filter(allowed),
[caps],
)
const visibleNavGroups: NavGroup[] = useMemo(
() =>
navGroups
.map((g) => ({ ...g, items: g.items.filter(allowed) }))
.filter((g) => g.items.length > 0),
[caps],
)
const visibleExtraItems = useMemo(
() => extraNavItems.filter(allowed),
[caps],
)
const visibleAllNavItems: NavItem[] = useMemo(
() => [
...visiblePinnedTop,
...visibleNavGroups.flatMap((g) => g.items),
...visibleExtraItems,
...visiblePinnedBottom,
],
[visiblePinnedTop, visibleNavGroups, visibleExtraItems, visiblePinnedBottom],
)
const activeGroupKey = useMemo(
() =>
visibleNavGroups.find((g) =>
g.items.some((it) => location.pathname.startsWith(it.to)),
)?.key ?? null,
[location.pathname, visibleNavGroups],
)
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() =>
readNavGroupState(),
)
// Auto-open the group that owns the current route on first mount or
// navigation, but never auto-close — the user's explicit toggles win.
useEffect(() => {
if (!activeGroupKey) return
setOpenGroups((prev) =>
prev[activeGroupKey] ? prev : { ...prev, [activeGroupKey]: true },
)
}, [activeGroupKey])
useEffect(() => {
if (typeof window === "undefined") return
localStorage.setItem(NAV_GROUPS_KEY, JSON.stringify(openGroups))
}, [openGroups])
const toggleGroup = (key: string) =>
setOpenGroups((prev) => ({ ...prev, [key]: !prev[key] }))
if (!session) return null
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-0.5 overflow-y-auto p-2">
{expanded ? (
<>
{visiblePinnedTop.map((item) => (
<NavRow key={item.label} item={item} expanded />
))}
{visibleNavGroups.map((group) => {
const isOpen = !!openGroups[group.key]
const GroupIcon = group.icon
return (
<div key={group.key} className="mt-3 flex flex-col gap-0.5">
<button
type="button"
data-action={`nav-group-${group.key}`}
onClick={() => toggleGroup(group.key)}
aria-expanded={isOpen}
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-left text-caption font-semibold uppercase tracking-wider text-muted-foreground/70 transition-colors duration-fast ease-standard hover:text-foreground"
>
<GroupIcon className="size-3.5 shrink-0" />
<span className="flex-1 truncate">{group.label}</span>
<ChevronDown
className={[
"size-3.5 shrink-0 transition-transform duration-fast ease-standard",
isOpen ? "" : "-rotate-90",
].join(" ")}
/>
</button>
{isOpen ? (
<div className="flex flex-col gap-0.5">
{group.items.map((item) => (
<NavRow key={item.label} item={item} expanded inGroup />
))}
</div>
) : null}
</div>
)
})}
{visibleExtraItems.length > 0 ? (
<div className="mt-1.5 flex flex-col gap-0.5">
{visibleExtraItems.map((item) => (
<NavRow key={item.label} item={item} expanded />
))}
</div>
) : null}
<div className="mt-auto flex flex-col gap-0.5 pt-2">
{visiblePinnedBottom.map((item) => (
<NavRow key={item.label} item={item} expanded />
))}
</div>
</>
) : (
// Icon-only rail: flat list, no group headers.
<>
{visibleAllNavItems.map((item) => (
<NavRow key={item.label} item={item} expanded={false} />
))}
</>
)}
</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">
{/* Mobile-only menu trigger, floating top-left of main */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger
data-action="mobile-nav-toggle"
aria-label="Open navigation"
className="fixed left-3 top-3 z-30 inline-flex size-9 items-center justify-center rounded-full border bg-card/70 text-muted-foreground shadow-sm backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground md:hidden"
>
<Menu className="size-5" />
</SheetTrigger>
<SheetContent
side="left"
className="flex h-svh w-72 flex-col p-0"
>
<SheetHeader className="shrink-0 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 min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
{visiblePinnedTop.map((item) => (
<NavRow
key={item.label}
item={item}
expanded
mobile
onNavigate={() => setMobileOpen(false)}
/>
))}
{visibleNavGroups.map((group) => {
const isOpen = !!openGroups[group.key]
const GroupIcon = group.icon
return (
<div key={group.key} className="mt-3 flex flex-col gap-0.5">
<button
type="button"
data-action={`nav-mobile-group-${group.key}`}
onClick={() => toggleGroup(group.key)}
aria-expanded={isOpen}
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-left text-caption font-semibold uppercase tracking-wider text-muted-foreground/70 transition-colors duration-fast ease-standard hover:text-foreground"
>
<GroupIcon className="size-3.5 shrink-0" />
<span className="flex-1 truncate">{group.label}</span>
<ChevronDown
className={[
"size-3.5 shrink-0 transition-transform duration-fast ease-standard",
isOpen ? "" : "-rotate-90",
].join(" ")}
/>
</button>
{isOpen ? (
<div className="flex flex-col gap-0.5">
{group.items.map((item) => (
<NavRow
key={item.label}
item={item}
expanded
mobile
inGroup
onNavigate={() => setMobileOpen(false)}
/>
))}
</div>
) : null}
</div>
)
})}
{visibleExtraItems.length > 0 ? (
<div className="mt-1.5 flex flex-col gap-0.5">
{visibleExtraItems.map((item) => (
<NavRow
key={item.label}
item={item}
expanded
mobile
onNavigate={() => setMobileOpen(false)}
/>
))}
</div>
) : null}
<div className="mt-auto flex flex-col gap-0.5 pt-2">
{visiblePinnedBottom.map((item) => (
<NavRow
key={item.label}
item={item}
expanded
mobile
onNavigate={() => setMobileOpen(false)}
/>
))}
</div>
</nav>
</SheetContent>
</Sheet>
{/* Floating glass pill, top-right, replacing the appbar action group */}
<div
data-slot="floating-actions"
className="fixed right-3 top-3 z-30 flex items-center gap-0.5 rounded-full border bg-card/70 px-1.5 py-1 shadow-sm backdrop-blur-md"
>
<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="ml-1 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-7 cursor-pointer">
{profile.avatarUrl ? (
<AvatarImage
key={profile.avatarUrl}
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>
<DropdownMenuSeparator />
<DropdownMenuItem
data-action="avatar-signout"
variant="destructive"
onClick={() => {
signOut()
navigate("/login", { replace: true })
}}
>
<LogOut /> Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div
id="main-content"
tabIndex={-1}
className="flex flex-1 flex-col focus:outline-none"
>
{/* Centered content column. Caps line lengths and frames pages
on wide displays so the canvas reads as composed instead of
one floating card in a sea of black. The floating actions
pill is fixed to the viewport edge and lives outside this
column, so it stays clear regardless of cap width. */}
<div className="mx-auto flex w-full max-w-[1180px] flex-1 flex-col gap-6 p-6 [&>*:first-child]:lg:pr-72">
<RouteGuard>{children}</RouteGuard>
</div>
</div>
</main>
<ScriptsDialog open={scriptsOpen} onOpenChange={setScriptsOpen} />
<NotificationDispatcher />
</div>
)
}
function NavRow({
item,
expanded,
mobile = false,
inGroup = false,
onNavigate,
}: {
item: NavItem
expanded: boolean
mobile?: boolean
/** True when rendered inside a collapsible group — hides the per-item
* icon and indents the label so it aligns under the group header. */
inGroup?: boolean
onNavigate?: () => void
}) {
const Icon = item.icon
const prefix = mobile ? "nav-mobile-" : "nav-"
// Icons are hidden inside groups in the expanded rail. The collapsed
// icon-only rail (expanded=false) always shows icons regardless.
const showIcon = !inGroup || !expanded
return (
<NavLink
to={item.to}
end={item.end}
title={expanded ? undefined : item.label}
onClick={onNavigate}
data-action={`${prefix}${item.label.toLowerCase()}`}
className={({ isActive }) =>
[
"relative flex items-center gap-3 rounded-lg py-2 text-sm font-medium transition-colors duration-fast ease-standard",
// 2px left accent rail on active. Absolute-positioned so the rail
// anchors to the rail's edge regardless of per-item left padding,
// and a fixed 14px height keeps it from filling tall rows.
"before:absolute before:left-0 before:top-1/2 before:h-3.5 before:w-[2px] before:-translate-y-1/2 before:rounded-r-full before:bg-primary before:opacity-0 before:transition-opacity before:duration-fast",
expanded
? inGroup
? // Indent the label by chevron(12) + gap(8) = 20px so it
// visually aligns under the group header label.
"justify-start pl-[1.625rem] pr-3"
: "justify-start px-3"
: "justify-center px-3",
isActive
? "bg-primary/[0.08] text-primary before:opacity-100"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
].join(" ")
}
>
{showIcon ? <Icon className="size-5 shrink-0" /> : null}
{expanded ? <span className="truncate">{item.label}</span> : null}
</NavLink>
)
}
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>
)
}