Hybrid traditional + AI-first webapp scaffold. Sibling to crema-app-template, adds the AI assistant surface, command bus, scripts dialog, and virtual cursor. What's pre-wired: - 6 routes: Overview, Resources, Activity, Assistant, Library, Settings - Collapsible rail + appbar + avatar dropdown shell (template code, not a lib) - Mobile sheet at <md - /assistant: streaming chat via @crema/llm-ui, mock fallback, model selector, token meter, retry probe, stop-while-streaming, persistent UI Control toggle - /settings: editable LM Studio endpoint + context window + response cap, with test-connection button - Markdown rendering for assistant replies; ```action``` blocks rendered as a small "Ran N actions" pill - ⌘⇧P script runner dialog + Play icon in the appbar - Two demo scripts in public/scripts/ - mightypix theme as default, scoped via <AppShell theme="mightypix"> Libs wired in tsconfig + app.css: - @crema/action-bus (the bus, parser, runner, cursor, provider, ws, llm-bridge) - @crema/llm-ui, @crema/chat-ui, @crema/aifirst-ui, @crema/notification-ui - lib-theme-mightypix Docs: - README.md — pitch + quick start + structure - docs/AI_FIRST.md — full system tour (data-action contract, bus, DSL, scripts, cursor, LLM integration) - app/components/layout/THEME_CONTRACT.md — every CSS variable a theme must declare - CLAUDE.md — orientation for an LLM working in the repo Genericized from comfy-cloud (the original prototype): - Brand defaults to "App" / Sparkles icon (override via app/lib/identity.ts) - User defaults to a stub (swap useUser() for real auth) - localStorage namespace is "crema.*" (was "comfy.*") Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
import { useEffect, useState } from "react"
|
|
|
|
const SIDEBAR_KEY = "crema.shell.sidebar"
|
|
import { NavLink, 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 { Avatar, AvatarFallback } from "~/components/ui/avatar"
|
|
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
|
|
}
|
|
|
|
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 brand = brandOverride ?? defaultBrand
|
|
const user = userOverride ?? defaultUser
|
|
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)
|
|
const navigate = useNavigate()
|
|
const BrandIcon = brand.icon
|
|
|
|
useScriptsHotkey(() => setScriptsOpen(true))
|
|
|
|
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>
|
|
<div className="relative ml-6 hidden md:block">
|
|
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
data-action="appbar-search"
|
|
placeholder="Search…"
|
|
className="h-9 w-80 pl-8"
|
|
/>
|
|
</div>
|
|
<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>
|
|
<ThemeToggle />
|
|
<Button
|
|
data-action="appbar-notifications"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="Notifications"
|
|
>
|
|
<Bell />
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
data-action="appbar-avatar"
|
|
aria-label="Account menu"
|
|
className="rounded-full outline-none ring-offset-2 ring-offset-background focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<Avatar className="size-8 cursor-pointer transition-opacity hover:opacity-80">
|
|
<AvatarFallback>{user.initials}</AvatarFallback>
|
|
</Avatar>
|
|
</button>
|
|
</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"
|
|
onSelect={() => navigate("/settings")}
|
|
>
|
|
<UserIcon /> Profile
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
data-action="avatar-settings"
|
|
onSelect={() => navigate("/settings")}
|
|
>
|
|
<Settings /> Settings
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem data-action="avatar-help">
|
|
<HelpCircle /> Help
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem data-action="avatar-signout" variant="destructive">
|
|
<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} />
|
|
</div>
|
|
)
|
|
}
|