Sidenav (app-shell.tsx): - Each NavGroup now carries an icon (Building2 / Database / Plug / MessageSquare / Eye / Sparkles) rendered on the LEFT of the group header, with the chevron moved to the RIGHT. Header typography switched to caption + uppercase + tracking-wider muted, matching pristine-ui's main-branch app-shell. Same change applied to the mobile sheet's group headers. /ai mobile fixes (ai.tsx): - Composer container honors iOS safe-area inset (pb-[max(0.75rem,env(safe-area-inset-bottom))]) so the input clears the home indicator and stays above the soft keyboard. - Composer toolbar wraps on narrow viewports (flex-wrap + gap-y-1) so the agent / model / reasoning / voice chips don't clip. - Empty-state card uses px-4 sm:px-8 instead of hard px-8. - MessageRow's 56px turn-number gutter collapses below sm: prose flows full-width on phone, two-column layout returns at sm+. /ai desktop centering: - Console wrapper opts out of AppShell's [&>*:first-child]:lg:pr-72 (the page-header clearance for the floating top-right pill) via lg:!pr-0. The /ai surface has no top-right page-header controls, so the inherited padding was shifting the chat column ~144px left of the visible viewport center. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
853 lines
28 KiB
TypeScript
853 lines
28 KiB
TypeScript
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,
|
||
HelpCircle,
|
||
Menu,
|
||
Play,
|
||
HardDrive,
|
||
Users as UsersIcon,
|
||
KeyRound,
|
||
Webhook as WebhookIcon,
|
||
CalendarClock,
|
||
Gauge,
|
||
UserCheck,
|
||
Network,
|
||
ShieldCheck,
|
||
Megaphone,
|
||
AlertOctagon,
|
||
SearchCode,
|
||
ChevronDown,
|
||
Database,
|
||
Plug,
|
||
MessageSquare,
|
||
Eye,
|
||
// 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,
|
||
seedIfEmpty,
|
||
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"
|
||
|
||
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.
|
||
const pinnedBottom: NavItem[] = [
|
||
{ to: "/settings", icon: Settings, label: "Settings" },
|
||
]
|
||
|
||
const navGroups: NavGroup[] = [
|
||
{
|
||
key: "tenancy",
|
||
label: "Tenancy",
|
||
icon: Building2,
|
||
items: [
|
||
{ to: "/tenants", icon: Building2, label: "Tenants" },
|
||
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
|
||
{ to: "/users", icon: UsersIcon, label: "Users" },
|
||
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
|
||
],
|
||
},
|
||
{
|
||
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: "integrations",
|
||
label: "Integrations",
|
||
icon: Plug,
|
||
items: [
|
||
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
|
||
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
|
||
{ to: "/networking", icon: Network, label: "Networking" },
|
||
],
|
||
},
|
||
{
|
||
key: "comms",
|
||
label: "Communications",
|
||
icon: MessageSquare,
|
||
items: [
|
||
{ to: "/announcements", icon: Megaphone, label: "Announcements" },
|
||
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
|
||
],
|
||
},
|
||
{
|
||
key: "observability",
|
||
label: "Observability",
|
||
icon: Eye,
|
||
items: [
|
||
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
|
||
{ to: "/activity", icon: Activity, label: "Audit log" },
|
||
],
|
||
},
|
||
{
|
||
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
|
||
]
|
||
|
||
// Flat list — used by the icon-only collapsed rail, where group headers
|
||
// don't render and items appear as a single column of icons.
|
||
const allNavItems: NavItem[] = [
|
||
...pinnedTop,
|
||
...navGroups.flatMap((g) => g.items),
|
||
...extraNavItems,
|
||
...pinnedBottom,
|
||
]
|
||
|
||
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 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 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))
|
||
|
||
const location = useLocation()
|
||
const activeGroupKey = useMemo(
|
||
() =>
|
||
navGroups.find((g) => g.items.some((it) => location.pathname.startsWith(it.to)))
|
||
?.key ?? null,
|
||
[location.pathname],
|
||
)
|
||
|
||
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 ? (
|
||
<>
|
||
{pinnedTop.map((item) => (
|
||
<NavRow key={item.label} item={item} expanded />
|
||
))}
|
||
|
||
{navGroups.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>
|
||
)
|
||
})}
|
||
|
||
{extraNavItems.length > 0 ? (
|
||
<div className="mt-1.5 flex flex-col gap-0.5">
|
||
{extraNavItems.map((item) => (
|
||
<NavRow key={item.label} item={item} expanded />
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-auto flex flex-col gap-0.5 pt-2">
|
||
{pinnedBottom.map((item) => (
|
||
<NavRow key={item.label} item={item} expanded />
|
||
))}
|
||
</div>
|
||
</>
|
||
) : (
|
||
// Icon-only rail: flat list, no group headers.
|
||
<>
|
||
{allNavItems.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">
|
||
{pinnedTop.map((item) => (
|
||
<NavRow
|
||
key={item.label}
|
||
item={item}
|
||
expanded
|
||
mobile
|
||
onNavigate={() => setMobileOpen(false)}
|
||
/>
|
||
))}
|
||
|
||
{navGroups.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>
|
||
)
|
||
})}
|
||
|
||
{extraNavItems.length > 0 ? (
|
||
<div className="mt-1.5 flex flex-col gap-0.5">
|
||
{extraNavItems.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">
|
||
{pinnedBottom.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>
|
||
<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>
|
||
</div>
|
||
|
||
<div
|
||
id="main-content"
|
||
tabIndex={-1}
|
||
// First-child padding clears the fixed top-right floating actions
|
||
// pill so page headers can put refresh/new buttons in their normal
|
||
// top-right slot without sliding under the appbar avatar/controls.
|
||
className="flex flex-1 flex-col gap-6 p-6 focus:outline-none [&>*:first-child]:lg:pr-72"
|
||
>
|
||
{children}
|
||
</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 }) =>
|
||
[
|
||
"flex items-center gap-3 rounded-lg py-2 text-sm font-medium transition-colors duration-fast ease-standard",
|
||
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/10 text-primary"
|
||
: "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()
|
||
|
||
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>
|
||
)
|
||
}
|