init: arcadia-admin — admin webapp for arcadia-core, cloned from vibespace
Initial commit. Spun up via the docs/STARTER.md recipe: cp from vibespace, reset git, rename package, set brand to "Arcadia Admin" with Shield icon in app/lib/identity.ts. Inherits the full Crema sibling-lib wiring including @crema/arcadia-client (typed HTTP + Phoenix Channels realtime against arcadia-core) and @crema/arcadia-auth-ui (login/signup/password-reset/2FA forms). The /login route already renders <LoginForm>; <ArcadiaProvider> in app/root.tsx reads VITE_ARCADIA_URL (default localhost:4000) and VITE_ARCADIA_TENANT (default "default"). CLAUDE.md and README rewritten to frame this as the admin app for arcadia-core. docs/STARTER.md removed — arcadia-admin is a leaf consumer, not a downstream starter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
app/components/layout/README.md
Normal file
10
app/components/layout/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# layout/
|
||||
|
||||
App structure and page chrome.
|
||||
|
||||
Examples: `AppShell`, `PageHeader`, `SidebarNav`, `Breadcrumbs`, `Footer`.
|
||||
|
||||
**Rules:**
|
||||
- Import from `~/components/ui/*` — never the reverse.
|
||||
- Reference tokens only. No hardcoded colors or spacing.
|
||||
- These compose `ui/sidebar`, `ui/navigation-menu`, etc. into opinionated shells.
|
||||
132
app/components/layout/THEME_CONTRACT.md
Normal file
132
app/components/layout/THEME_CONTRACT.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Theme contract
|
||||
|
||||
Every Crema theme (`lib-theme-*`) must declare these CSS variables so the
|
||||
shell, AI surfaces, and shadcn primitives render correctly. Missing a variable
|
||||
won't crash anything — it'll just look wrong (transparent backgrounds, missing
|
||||
borders, broken focus rings, etc.).
|
||||
|
||||
A theme is a single CSS file that declares variables under `:root` for light
|
||||
mode and under `.dark` for dark mode. Themes may also `@import url(...)` font
|
||||
packages at the top of the file — they ship self-contained.
|
||||
|
||||
The shell expects the theme to be the **first** import in the consuming app's
|
||||
entry CSS, so any embedded font URLs land at the top of the resolved output
|
||||
(CSS spec requires `@import` before any other rules).
|
||||
|
||||
## Required tokens
|
||||
|
||||
### Surfaces
|
||||
| Token | Used for |
|
||||
|---|---|
|
||||
| `--background` | Page background |
|
||||
| `--foreground` | Default text color |
|
||||
| `--card` | Card surfaces, inputs, dropdowns |
|
||||
| `--card-foreground` | Text on cards |
|
||||
| `--popover` | Popover/menu surfaces |
|
||||
| `--popover-foreground` | Text on popovers |
|
||||
| `--muted` | Subtle backgrounds (hover states, toolbars, code blocks) |
|
||||
| `--muted-foreground` | Subtle text (timestamps, hints, labels) |
|
||||
| `--accent` | Hover/focus highlight on items |
|
||||
| `--accent-foreground` | Text on accent backgrounds |
|
||||
|
||||
### Brand & status
|
||||
| Token | Used for |
|
||||
|---|---|
|
||||
| `--primary` | Brand color, primary buttons, active nav, ripple |
|
||||
| `--primary-foreground` | Text on primary surfaces |
|
||||
| `--secondary` | Secondary buttons |
|
||||
| `--secondary-foreground` | Text on secondary |
|
||||
| `--destructive` | Errors, sign-out, destructive menu items |
|
||||
| `--success` | Confirmations |
|
||||
| `--success-foreground` | Text on success surfaces |
|
||||
|
||||
### Borders, inputs, focus
|
||||
| Token | Used for |
|
||||
|---|---|
|
||||
| `--border` | Dividers, default borders |
|
||||
| `--input` | Input field borders |
|
||||
| `--ring` | Focus rings |
|
||||
|
||||
### Sidebar (rail)
|
||||
| Token | Used for |
|
||||
|---|---|
|
||||
| `--sidebar` | Rail background |
|
||||
| `--sidebar-foreground` | Rail text |
|
||||
| `--sidebar-primary` | Active nav background |
|
||||
| `--sidebar-primary-foreground` | Active nav text |
|
||||
| `--sidebar-accent` | Rail hover |
|
||||
| `--sidebar-accent-foreground` | Text on rail hover |
|
||||
| `--sidebar-border` | Rail borders |
|
||||
| `--sidebar-ring` | Rail focus ring |
|
||||
|
||||
### Charts & syntax (optional but recommended)
|
||||
| Token | Used for |
|
||||
|---|---|
|
||||
| `--chart-1` … `--chart-5` | Chart series colors; `--chart-3` doubles as warning amber |
|
||||
| `--syntax-keyword`, `--syntax-string`, `--syntax-number`, `--syntax-comment`, `--syntax-function`, `--syntax-type` | Code-block highlighting |
|
||||
|
||||
### Typography
|
||||
| Token | Used for |
|
||||
|---|---|
|
||||
| `--font-heading` | Heading font stack |
|
||||
| `--font-sans` | Default UI font stack |
|
||||
| `--font-ai-prose` | Assistant prose (the AI surface uses this for replies) |
|
||||
| `--font-mono` | Code, tabular numbers |
|
||||
|
||||
### Type scale
|
||||
Each pair declares a size and matching line height:
|
||||
- `--text-display-size` / `--text-display-lh`
|
||||
- `--text-headline-size` / `--text-headline-lh`
|
||||
- `--text-title-size` / `--text-title-lh`
|
||||
- `--text-body-size` / `--text-body-lh`
|
||||
- `--text-label-size` / `--text-label-lh`
|
||||
- `--text-caption-size` / `--text-caption-lh`
|
||||
|
||||
### Motion
|
||||
| Token | Used for |
|
||||
|---|---|
|
||||
| `--duration-fast`, `--duration-base`, `--duration-slow`, `--duration-slower`, `--duration-spring` | Animation lengths |
|
||||
| `--ease-standard`, `--ease-emphasized`, `--ease-decelerate`, `--ease-accelerate` | Standard easing curves |
|
||||
| `--ease-spring-gentle`, `--ease-spring-snappy`, `--ease-spring-bouncy` | Spring curves |
|
||||
|
||||
### Radii & elevation
|
||||
| Token | Used for |
|
||||
|---|---|
|
||||
| `--radius` | Base radius (sm/md/lg/xl/2xl computed from this in `app.css`) |
|
||||
| `--elevation-0` … `--elevation-5` | Shadow scale |
|
||||
| `--border-width-thin`, `--border-width-base`, `--border-width-thick`, `--border-width-heavy` | Stroke widths |
|
||||
|
||||
## Scoping conventions
|
||||
|
||||
- `:root { … }` — theme defaults (light mode).
|
||||
- `.dark { … }` — dark-mode overrides.
|
||||
- `[data-theme="<name>"] { … }` — alternate-scoped theme. Only needed if a
|
||||
theme should coexist with another on the same page (e.g. an AI surface uses
|
||||
a different theme than the rest of the app). Single-theme apps don't need
|
||||
this scope.
|
||||
|
||||
## Adding a new theme
|
||||
|
||||
1. Clone any existing theme as a starting point — `lib-theme-mightypix` is a
|
||||
good reference because it's complete.
|
||||
2. Edit tokens (and font `@import url` if changing fonts).
|
||||
3. Update the consuming app's `app.css`:
|
||||
```css
|
||||
@import "../../lib-theme-<your-name>/theme.css"; /* CREMA:THEME */
|
||||
@import "tailwindcss";
|
||||
...
|
||||
```
|
||||
4. Done. Components automatically pick up the new look — no changes needed in
|
||||
any TSX file.
|
||||
|
||||
## Multi-theme apps (e.g. mightypix on AI surfaces only)
|
||||
|
||||
If a theme defines `[data-theme="<name>"]` instead of (or in addition to)
|
||||
`:root`, you can switch surfaces per route via the shell's `theme` prop:
|
||||
|
||||
```tsx
|
||||
<AppShell title="Assistant" theme="mightypix">…</AppShell>
|
||||
```
|
||||
|
||||
The shell wraps itself in `data-theme={theme}` so the alt theme's tokens
|
||||
cascade through that subtree only.
|
||||
568
app/components/layout/app-shell.tsx
Normal file
568
app/components/layout/app-shell.tsx
Normal file
@@ -0,0 +1,568 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
const SIDEBAR_KEY = "crema.shell.sidebar"
|
||||
import { NavLink, useNavigate } from "react-router"
|
||||
import {
|
||||
Bell,
|
||||
LayoutDashboard,
|
||||
Boxes,
|
||||
Activity,
|
||||
Sparkles,
|
||||
Bot,
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
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: "/ai", icon: Bot, label: "AI" },
|
||||
{ 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,
|
||||
),
|
||||
}
|
||||
|
||||
// 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"
|
||||
})
|
||||
useEffect(() => {
|
||||
localStorage.setItem(SIDEBAR_KEY, expanded ? "1" : "0")
|
||||
}, [expanded])
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [scriptsOpen, setScriptsOpen] = useState(false)
|
||||
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">
|
||||
{/* 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="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>
|
||||
|
||||
{/* 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 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}
|
||||
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()
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
48
app/components/layout/appbar.tsx
Normal file
48
app/components/layout/appbar.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function Appbar({ className, ...props }: ComponentProps<"header">) {
|
||||
return (
|
||||
<header
|
||||
data-slot="appbar"
|
||||
className={cn(
|
||||
"flex h-14 w-full items-center gap-3 border-b bg-background px-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AppbarTitle({ className, ...props }: ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="appbar-title"
|
||||
className={cn("font-heading text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AppbarSpacer({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="appbar-spacer"
|
||||
className={cn("flex-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AppbarActions({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="appbar-actions"
|
||||
className={cn("flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Appbar, AppbarTitle, AppbarSpacer, AppbarActions }
|
||||
112
app/components/layout/background-picker.tsx
Normal file
112
app/components/layout/background-picker.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Check, Palette } from "lucide-react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
export const backgrounds = [
|
||||
{ id: "none", label: "None" },
|
||||
{ id: "pearl", label: "Pearl" },
|
||||
{ id: "linen", label: "Linen" },
|
||||
{ id: "mist", label: "Mist" },
|
||||
{ id: "dawn", label: "Dawn" },
|
||||
{ id: "seafoam", label: "Seafoam" },
|
||||
{ id: "aurora", label: "Aurora" },
|
||||
{ id: "sunset", label: "Sunset" },
|
||||
{ id: "meadow", label: "Meadow" },
|
||||
{ id: "midnight", label: "Midnight" },
|
||||
{ id: "blush", label: "Blush" },
|
||||
{ id: "noir", label: "Noir" },
|
||||
] as const
|
||||
|
||||
export type BackgroundId = (typeof backgrounds)[number]["id"]
|
||||
|
||||
const STORAGE_KEY = "crema-bg"
|
||||
const DEFAULT_BG: BackgroundId = "none"
|
||||
|
||||
function isBackgroundId(value: string | null): value is BackgroundId {
|
||||
return !!value && backgrounds.some((b) => b.id === value)
|
||||
}
|
||||
|
||||
function applyBg(id: BackgroundId) {
|
||||
if (id === "none") {
|
||||
delete document.body.dataset.bg
|
||||
} else {
|
||||
document.body.dataset.bg = id
|
||||
}
|
||||
}
|
||||
|
||||
export function BackgroundPicker() {
|
||||
const [current, setCurrent] = useState<BackgroundId>(DEFAULT_BG)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
const next = isBackgroundId(stored) ? stored : DEFAULT_BG
|
||||
setCurrent(next)
|
||||
applyBg(next)
|
||||
}, [])
|
||||
|
||||
const select = (id: BackgroundId) => {
|
||||
setCurrent(id)
|
||||
applyBg(id)
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
data-action="appbar-background"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Change background"
|
||||
>
|
||||
<Palette />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-64">
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>Background</PopoverTitle>
|
||||
<PopoverDescription>Pick an atmosphere.</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{backgrounds.map((bg) => {
|
||||
const active = current === bg.id
|
||||
return (
|
||||
<button
|
||||
key={bg.id}
|
||||
type="button"
|
||||
onClick={() => select(bg.id)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"group relative flex aspect-square flex-col justify-end overflow-hidden rounded-lg ring-1 ring-border transition-all duration-fast ease-standard hover:ring-foreground/30 focus-visible:ring-2 focus-visible:ring-ring",
|
||||
bg.id === "none" ? "bg-background" : `bg-variant-${bg.id}`,
|
||||
active && "ring-2 ring-primary"
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute top-1 right-1 flex size-4 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-e1">
|
||||
<Check className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-b-lg bg-background/70 px-1.5 py-1 text-center text-[10px] font-medium tracking-tight backdrop-blur-sm">
|
||||
{bg.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
104
app/components/layout/font-size-picker.tsx
Normal file
104
app/components/layout/font-size-picker.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Check, Type } from "lucide-react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
export const fontSizes = [
|
||||
{ id: "sm", label: "Small", sample: "Aa" },
|
||||
{ id: "md", label: "Medium", sample: "Aa" },
|
||||
{ id: "lg", label: "Large", sample: "Aa" },
|
||||
{ id: "xl", label: "Extra large", sample: "Aa" },
|
||||
] as const
|
||||
|
||||
export type FontSizeId = (typeof fontSizes)[number]["id"]
|
||||
|
||||
const STORAGE_KEY = "crema-font-scale"
|
||||
const DEFAULT_SIZE: FontSizeId = "md"
|
||||
|
||||
function isFontSizeId(value: string | null): value is FontSizeId {
|
||||
return !!value && fontSizes.some((f) => f.id === value)
|
||||
}
|
||||
|
||||
const SAMPLE_PX: Record<FontSizeId, number> = {
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 18,
|
||||
xl: 20,
|
||||
}
|
||||
|
||||
export function FontSizePicker() {
|
||||
const [current, setCurrent] = useState<FontSizeId>(DEFAULT_SIZE)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
const next = isFontSizeId(stored) ? stored : DEFAULT_SIZE
|
||||
setCurrent(next)
|
||||
document.documentElement.dataset.fontScale = next
|
||||
}, [])
|
||||
|
||||
const select = (id: FontSizeId) => {
|
||||
setCurrent(id)
|
||||
document.documentElement.dataset.fontScale = id
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
data-action="appbar-font-size"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Change font size"
|
||||
>
|
||||
<Type />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-56">
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>Font size</PopoverTitle>
|
||||
<PopoverDescription>Scales the entire UI.</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
<div className="flex flex-col gap-1">
|
||||
{fontSizes.map((f) => {
|
||||
const active = current === f.id
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
onClick={() => select(f.id)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left transition-colors duration-fast ease-standard hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:outline-none",
|
||||
active && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-baseline gap-2.5">
|
||||
<span
|
||||
className="font-heading leading-none"
|
||||
style={{ fontSize: `${SAMPLE_PX[f.id]}px` }}
|
||||
>
|
||||
{f.sample}
|
||||
</span>
|
||||
<span className="text-sm">{f.label}</span>
|
||||
</span>
|
||||
{active && <Check className="size-4 text-primary" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
107
app/components/layout/surface-picker.tsx
Normal file
107
app/components/layout/surface-picker.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Check, Layers } from "lucide-react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
export const surfaces = [
|
||||
{ id: "default", label: "Default" },
|
||||
{ id: "snow", label: "Snow" },
|
||||
{ id: "stone", label: "Stone" },
|
||||
{ id: "sage", label: "Sage" },
|
||||
{ id: "slate", label: "Slate" },
|
||||
] as const
|
||||
|
||||
export type SurfaceId = (typeof surfaces)[number]["id"]
|
||||
|
||||
const STORAGE_KEY = "crema-surface"
|
||||
const DEFAULT_SURFACE: SurfaceId = "default"
|
||||
|
||||
function isSurfaceId(value: string | null): value is SurfaceId {
|
||||
return !!value && surfaces.some((s) => s.id === value)
|
||||
}
|
||||
|
||||
function applySurface(id: SurfaceId) {
|
||||
if (id === "default") {
|
||||
delete document.body.dataset.surface
|
||||
} else {
|
||||
document.body.dataset.surface = id
|
||||
}
|
||||
}
|
||||
|
||||
export function SurfacePicker() {
|
||||
const [current, setCurrent] = useState<SurfaceId>(DEFAULT_SURFACE)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
const next = isSurfaceId(stored) ? stored : DEFAULT_SURFACE
|
||||
setCurrent(next)
|
||||
applySurface(next)
|
||||
}, [])
|
||||
|
||||
const select = (id: SurfaceId) => {
|
||||
setCurrent(id)
|
||||
applySurface(id)
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
data-action="appbar-surface"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Change surface tint"
|
||||
>
|
||||
<Layers />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-56">
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>Surface</PopoverTitle>
|
||||
<PopoverDescription>Tint of cards and the sidebar.</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
<div className="flex flex-col gap-1">
|
||||
{surfaces.map((s) => {
|
||||
const active = current === s.id
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => select(s.id)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-fast ease-standard hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:outline-none",
|
||||
active && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-5 rounded-md ring-1 ring-border",
|
||||
`surface-swatch-${s.id}`
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">{s.label}</span>
|
||||
</span>
|
||||
{active && <Check className="size-4 text-primary" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
30
app/components/layout/theme-toggle.tsx
Normal file
30
app/components/layout/theme-toggle.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
|
||||
const STORAGE_KEY = "crema-theme"
|
||||
|
||||
function applyTheme(next: "light" | "dark") {
|
||||
document.documentElement.classList.toggle("dark", next === "dark")
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const toggle = () => {
|
||||
const isDark = document.documentElement.classList.contains("dark")
|
||||
applyTheme(isDark ? "light" : "dark")
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-action="theme-toggle"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Toggle theme"
|
||||
onClick={toggle}
|
||||
>
|
||||
<Sun className="hidden dark:block" />
|
||||
<Moon className="block dark:hidden" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user