feat(dashboard): add multi-theme system and supporting improvements
Add complete multi-theme dashboard system including: - 8 selectable themes with persistent per-browser preferences - Theme bootstrap script to prevent FOUC on page load - 12+ reusable chart components (area, bar, donut, radial gauge, etc) - Custom checkbox UI component - New settings page for appearance customization Other changes: - Optimize supplier map API payload with getMappableSuppliers helper - Add aria-label attributes to back buttons on detail pages for better accessibility - Update next.config.ts to include new local development IP 192.168.1.128 - Update site manifest with all new favicon and PWA assets - Fix styling and layout spacing in rewrite quote modal
|
|
@ -15,7 +15,7 @@ A modern business CRM (customers, companies, suppliers, quotes, orders, driver c
|
|||
## Prerequisites
|
||||
|
||||
- **Node.js >= 20.9.0** (enforced by `package.json` `engines`)
|
||||
- **PocketBase** binary (the setup script connects to `http://127.0.0.1:8090`)
|
||||
- **PocketBase** binary (the setup script connects to `http://127.0.0.1:8090`)but in my .env.local we have added the cloud url that its connecting to now at the moment
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -51,6 +51,7 @@ Create `.env.local` (gitignored) with:
|
|||
# Public — exposed to the browser
|
||||
NEXT_PUBLIC_POCKETBASE_URL=http://127.0.0.1:8090
|
||||
|
||||
|
||||
# Server-only — admin used by setup / migration scripts and agent API
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
PB_ADMIN_PASSWORD=your_password
|
||||
|
|
|
|||
90
docs/theme-preview/README.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# WFDASH — Multi-theme system
|
||||
|
||||
Ship 8 selectable themes (5 standard + 3 standout) with a warm/dark mode toggle.
|
||||
Theme selection persists per browser. No FOUC on reload.
|
||||
|
||||
## File map
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── themes.ts # 8-theme registry (id, label, fonts, swatches)
|
||||
│ └── use-theme-id.ts # Client hook to read/write active theme
|
||||
├── styles/
|
||||
│ └── themes.css # OKLCH token blocks for all 8 themes (warm + dark)
|
||||
├── components/
|
||||
│ ├── theme-provider.tsx # next-themes wrapper (manages warm/dark mode)
|
||||
│ ├── theme-bootstrap.tsx # Inline <head> script — prevents FOUC
|
||||
│ ├── theme-picker.tsx # Settings UI — mini preview cards
|
||||
│ └── sidebar.tsx # +Settings nav item
|
||||
└── app/
|
||||
├── layout.tsx # +Inter, Space Grotesk, DM Sans, Playfair,
|
||||
│ # Lato, Nunito, Outfit, JetBrains Mono via next/font
|
||||
├── globals.css # Imports themes.css
|
||||
└── (app)/settings/page.tsx # New Settings page with Appearance section
|
||||
```
|
||||
|
||||
## The 8 themes
|
||||
|
||||
| # | ID | Category | Fonts | Radius | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | `crisp` | Standard | Inter | 10px | Default. Calm, professional. |
|
||||
| 2 | `midnight` | Standard | Space Grotesk + DM Sans | 12px | Dark control-room. |
|
||||
| 3 | `editorial` | Standard | Playfair Display + Lato | 6px | Warm cream + serif. |
|
||||
| 4 | `bento` | Standard | Inter | 18px | Tinted tiles, big radius. |
|
||||
| 5 | `field-ops` | Standard | Inter | 8px | Dense, navy sidebar, gold CTA. |
|
||||
| 6 | `terminal` | **Standout** | JetBrains Mono | 0px | Green CRT, scanlines, mono. |
|
||||
| 7 | `blueprint` | **Standout** | JetBrains Mono + Inter | 2px | White-on-blue, grid, technical. |
|
||||
| 8 | `confetti` | **Standout** | Outfit + Nunito | 20px | Pastel, playful, oversized radius. |
|
||||
|
||||
## How it works
|
||||
|
||||
**Theme identity** = `data-theme="<id>"` on `<html>`
|
||||
- Set by `theme-bootstrap.tsx` before hydration (prevents flash)
|
||||
- Updated by `useThemeId()` hook when user picks a new theme
|
||||
- Persisted to `localStorage["wfdash-theme"]`
|
||||
|
||||
**Mode (warm/dark)** = `.warm` or `.dark` class on `<html>`
|
||||
- Managed by `next-themes` (already wired in `theme-provider.tsx`)
|
||||
- Persisted to `localStorage["wfdash-mode"]`
|
||||
- Independent from theme — you can mix any theme with any mode
|
||||
|
||||
**shadcn/ui adapts automatically** because all components read from CSS vars
|
||||
(`--background`, `--primary`, `--accent`, `--radius`, etc.) that themes.css
|
||||
overrides per theme.
|
||||
|
||||
## How to add a new theme
|
||||
|
||||
1. Add an entry to `THEMES` in `src/lib/themes.ts`
|
||||
2. Add a `[data-theme="<id>"]` block in `src/styles/themes.css` (copy an existing
|
||||
theme and adjust tokens). Add `.dark` variant if it works in dark mode.
|
||||
3. Add `radiusMap[<id>]` in `theme-bootstrap.tsx`
|
||||
4. Done — picker picks it up automatically
|
||||
|
||||
## How to ship a font swap
|
||||
|
||||
Each theme has `fontHeading` + `fontBody` pointing at a CSS var. Add the font to
|
||||
`src/app/layout.tsx` via `next/font/google` and register its `.variable`. Then
|
||||
add the binding in `src/styles/themes.css`:
|
||||
|
||||
```css
|
||||
[data-theme="<id>"] {
|
||||
--font-heading: var(--font-<your-font>);
|
||||
--font-body: var(--font-<your-font>);
|
||||
}
|
||||
```
|
||||
|
||||
The body picks up `var(--font-body)` automatically; `font-heading-theme` class
|
||||
applies heading font where you need it.
|
||||
|
||||
## Standout theme caveats
|
||||
|
||||
The 3 standout themes override default component styling:
|
||||
|
||||
- **Terminal** — sharp 0px radius, uppercase headings, mono everywhere, scanline body overlay
|
||||
- **Blueprint** — sharp 2px radius, uppercase headings, grid overlay, transparent inputs
|
||||
- **Confetti** — 20px radius (capped — shadcn's `--radius-4xl` calc clamps at ~1.6rem)
|
||||
|
||||
All three are still built on the brand palette (#4C96D7 / #F2AF58 / #E94E49) but
|
||||
applied in different ways. They're tested against the standard shadcn components
|
||||
but may render slightly differently from the 5 standard themes — by design.
|
||||
1268
docs/theme-preview/themes.html
Normal file
|
|
@ -6,7 +6,7 @@ const nextConfig: NextConfig = {
|
|||
// app is normally loaded on `localhost`, but we also load it on
|
||||
// `127.0.0.1` (and on the LAN IP for phone testing). Listing both keeps
|
||||
// the HMR client from hanging on the first load.
|
||||
allowedDevOrigins: ["127.0.0.1", "localhost", "192.168.1.122"],
|
||||
allowedDevOrigins: ["127.0.0.1", "localhost", "192.168.1.122", "192.168.1.128"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
BIN
public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 663 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 685 B |
|
|
@ -8,6 +8,33 @@
|
|||
"theme_color": "#E85D4C",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon-16x16.png",
|
||||
"sizes": "16x16",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicon-32x32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
|
|
|
|||
BIN
public/og-image.png
Normal file
|
After Width: | Height: | Size: 732 KiB |
BIN
public/og-square.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/twitter-image.png
Normal file
|
After Width: | Height: | Size: 772 KiB |
|
|
@ -63,7 +63,7 @@ export default function CompanyDetailPage({ params }: { params: Promise<{ id: st
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/companies")}>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/companies")} aria-label="Back to companies">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/customers")}>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/customers")} aria-label="Back to customers">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/orders")}>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/orders")} aria-label="Back to orders">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DeleteDialog } from "@/components/delete-dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -147,7 +148,7 @@ function itemMarkupPercent(item: QuoteItem): number | null {
|
|||
function markupBadgeClasses(pct: number | null): string {
|
||||
if (pct === null) return "text-muted-foreground";
|
||||
if (pct < 20) return "text-destructive font-semibold";
|
||||
return "text-green-700 font-semibold dark:text-green-400";
|
||||
return "text-primary font-semibold";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1304,7 +1305,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/quotes")} className="shrink-0">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/quotes")} className="shrink-0" aria-label="Back to quotes">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
@ -1325,7 +1326,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setApproveOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-red-700 hover:text-red-800 dark:text-red-300 dark:hover:text-red-200 hover:underline"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-destructive hover:underline"
|
||||
title={quote.email_error || "Last send failed"}
|
||||
>
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
|
|
@ -1333,7 +1334,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
|
|||
</button>
|
||||
)}
|
||||
{quote.status === "sending" && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-blue-700 dark:text-blue-300">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-primary">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Sending in progress
|
||||
</span>
|
||||
|
|
@ -1896,7 +1897,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
|
|||
<p className="text-xs text-muted-foreground">Delivered</p>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{quote.email_delivered ? (
|
||||
<Mail className="h-4 w-4 text-green-500" />
|
||||
<Mail className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
|
|
@ -1907,7 +1908,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
|
|||
<p className="text-xs text-muted-foreground">Opened</p>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{quote.email_opened ? (
|
||||
<MailOpen className="h-4 w-4 text-green-500" />
|
||||
<MailOpen className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<MailOpen className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
|
|
@ -2945,9 +2946,9 @@ function ApproveAndSendDialog({
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl w-[calc(100%-2rem)] max-h-[90vh] p-0 gap-0 overflow-hidden flex flex-col">
|
||||
<DialogHeader className="px-6 py-4 border-b">
|
||||
<DialogHeader className="px-6 py-4 border-b shrink-0 space-y-0">
|
||||
<DialogTitle className="text-lg flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<CheckCircle className="h-5 w-5 text-primary" />
|
||||
Approve Quote #{quote.quote_number}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
|
@ -2957,18 +2958,38 @@ function ApproveAndSendDialog({
|
|||
{showFailureBanner && (
|
||||
<div
|
||||
data-testid="approve-failure-banner"
|
||||
className="rounded-md border border-red-300 bg-red-50 dark:border-red-500/40 dark:bg-red-500/10 p-4 flex items-start gap-3"
|
||||
className="rounded-md border p-4 flex items-start gap-3"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in oklch, var(--destructive) 30%, transparent)",
|
||||
background:
|
||||
"color-mix(in oklch, var(--destructive) 5%, transparent)",
|
||||
color: "var(--destructive)",
|
||||
}}
|
||||
>
|
||||
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
|
||||
<AlertCircle
|
||||
className="h-5 w-5 shrink-0 mt-0.5"
|
||||
style={{ color: "var(--destructive)" }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm text-red-900 dark:text-red-100">
|
||||
Last send failed
|
||||
</p>
|
||||
<p className="text-xs text-red-700 dark:text-red-200 mt-1 whitespace-pre-wrap break-words">
|
||||
<p className="font-medium text-sm">Last send failed</p>
|
||||
<p
|
||||
className="text-xs mt-1 whitespace-pre-wrap break-words"
|
||||
style={{
|
||||
color:
|
||||
"color-mix(in oklch, var(--destructive) 80%, transparent)",
|
||||
}}
|
||||
>
|
||||
{failureReason || "Unknown failure (no error message recorded)"}
|
||||
</p>
|
||||
{quote.email_failed_at && (
|
||||
<p className="text-xs text-red-600/80 dark:text-red-300/80 mt-1">
|
||||
<p
|
||||
className="text-xs mt-1 opacity-75"
|
||||
style={{
|
||||
color:
|
||||
"color-mix(in oklch, var(--destructive) 70%, transparent)",
|
||||
}}
|
||||
>
|
||||
{new Date(quote.email_failed_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -2978,7 +2999,11 @@ function ApproveAndSendDialog({
|
|||
variant="outline"
|
||||
onClick={handleRetry}
|
||||
disabled={submitting || quote.status === "sending"}
|
||||
className="shrink-0 border-red-300 hover:bg-red-100 dark:border-red-500/40 dark:hover:bg-red-500/20"
|
||||
className="shrink-0"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in oklch, var(--destructive) 30%, transparent)",
|
||||
}}
|
||||
>
|
||||
{submitting ? (
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
|
|
@ -2993,14 +3018,28 @@ function ApproveAndSendDialog({
|
|||
{showSendingBanner && (
|
||||
<div
|
||||
data-testid="approve-sending-banner"
|
||||
className="rounded-md border border-blue-300 bg-blue-50 dark:border-blue-500/40 dark:bg-blue-500/10 p-4 flex items-start gap-3"
|
||||
className="rounded-md border p-4 flex items-start gap-3"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in oklch, var(--primary) 30%, transparent)",
|
||||
background:
|
||||
"color-mix(in oklch, var(--primary) 5%, transparent)",
|
||||
color: "var(--primary)",
|
||||
}}
|
||||
>
|
||||
<Loader2 className="h-5 w-5 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5 animate-spin" />
|
||||
<Loader2
|
||||
className="h-5 w-5 shrink-0 mt-0.5 animate-spin"
|
||||
style={{ color: "var(--primary)" }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm text-blue-900 dark:text-blue-100">
|
||||
Sending email…
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-200 mt-1">
|
||||
<p className="font-medium text-sm">Sending email…</p>
|
||||
<p
|
||||
className="text-xs mt-1"
|
||||
style={{
|
||||
color:
|
||||
"color-mix(in oklch, var(--primary) 80%, transparent)",
|
||||
}}
|
||||
>
|
||||
{sendingElapsed
|
||||
? `In progress for ${sendingElapsed}`
|
||||
: "Just started"}
|
||||
|
|
@ -3029,13 +3068,12 @@ function ApproveAndSendDialog({
|
|||
|
||||
{/* Send-email toggle (default ON) */}
|
||||
<div className="rounded-md border p-4 flex items-start gap-3">
|
||||
<input
|
||||
<Checkbox
|
||||
id="approve-send-email"
|
||||
type="checkbox"
|
||||
checked={sendEmail}
|
||||
onChange={(e) => setSendEmail(e.target.checked)}
|
||||
onCheckedChange={(v) => setSendEmail(v === true)}
|
||||
disabled={submitting}
|
||||
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<label htmlFor="approve-send-email" className="text-sm font-medium cursor-pointer">
|
||||
|
|
@ -3048,16 +3086,15 @@ function ApproveAndSendDialog({
|
|||
</div>
|
||||
|
||||
{sendEmail && (
|
||||
<div className="space-y-4 pl-1 border-l-2 border-primary/20 ml-2 pl-4">
|
||||
<div className="space-y-4 rounded-lg bg-muted/30 border p-4">
|
||||
{/* Attach auth letters sub-toggle */}
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
<Checkbox
|
||||
id="approve-attach-auth"
|
||||
type="checkbox"
|
||||
checked={attachAuthLetters}
|
||||
onChange={(e) => setAttachAuthLetters(e.target.checked)}
|
||||
onCheckedChange={(v) => setAttachAuthLetters(v === true)}
|
||||
disabled={submitting}
|
||||
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="approve-attach-auth" className="text-sm font-medium cursor-pointer">
|
||||
|
|
@ -3172,13 +3209,12 @@ function ApproveAndSendDialog({
|
|||
return (
|
||||
<li key={att.id}>
|
||||
<label className={`flex items-center gap-2 px-3 py-2 rounded-md border bg-card hover:bg-accent/30 transition-colors cursor-pointer ${!isPdf && submitting ? "opacity-50" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={included}
|
||||
onChange={() => isPdf ? undefined : toggleAttachment(att.id)}
|
||||
onCheckedChange={() => isPdf ? undefined : toggleAttachment(att.id)}
|
||||
disabled={isPdf || submitting}
|
||||
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||
title={isPdf ? "The PDF is always attached" : "Toggle to include this file"}
|
||||
aria-label={`Include ${att.filename}`}
|
||||
/>
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 min-w-0 text-sm truncate">{att.filename}</span>
|
||||
|
|
|
|||
|
|
@ -392,7 +392,7 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
|
|||
<div className="space-y-4 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push(`/quotes/${id}`)}>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push(`/quotes/${id}`)} aria-label="Back to quote">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export default function ApprovalsPage() {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/quotes")}>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/quotes")} aria-label="Back to quotes">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export default function ArchivedQuotesPage() {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/quotes")}>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/quotes")} aria-label="Back to quotes">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
|
|
|
|||
81
src/app/(app)/settings/page.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ThemePicker } from "@/components/theme-picker";
|
||||
import { THEMES } from "@/lib/themes";
|
||||
import { Palette, Layers, Sparkles } from "lucide-react";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const standardCount = THEMES.filter((t) => t.category === "standard").length;
|
||||
const standoutCount = THEMES.filter((t) => t.category === "standout").length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight font-heading-theme">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage your WFDASH preferences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-4 w-4 text-primary" />
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Pick how WFDASH looks for you. Theme defines colors, fonts, and shape
|
||||
language. Mode switches the light/dark surface treatment. Both choices
|
||||
persist per browser.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ThemePicker />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-primary" />
|
||||
<CardTitle>About themes</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
WFDASH ships <b>{standardCount}</b> standard themes + <b>{standoutCount}</b>{" "}
|
||||
standout themes, all built on the same brand palette (#4C96D7 blue,
|
||||
#F2AF58 gold, #E94E49 red).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 font-semibold text-foreground">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
Standard themes
|
||||
</div>
|
||||
<p className="mt-1 leading-relaxed">
|
||||
Tested against every shadcn/ui component — dialogs, popovers, dropdowns,
|
||||
sonner toasts, tooltips, forms, tables all render cleanly. Safe defaults
|
||||
for everyone on the team.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 font-semibold text-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Standout themes
|
||||
</div>
|
||||
<p className="mt-1 leading-relaxed">
|
||||
Creative options for users who want their CRM to feel different. Some
|
||||
overrides on default component styling (sharp corners, monospace,
|
||||
uppercase headings) so a few components render slightly differently
|
||||
from the standard set.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "../styles/themes.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant warm (&:is(.warm *));
|
||||
|
|
@ -49,39 +50,21 @@
|
|||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.985 0.01 245);
|
||||
--foreground: oklch(0.22 0.045 245);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.22 0.045 245);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.22 0.045 245);
|
||||
--primary: oklch(0.47 0.16 245);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.95 0.025 245);
|
||||
--secondary-foreground: oklch(0.24 0.08 245);
|
||||
--muted: oklch(0.95 0.012 245);
|
||||
--muted-foreground: oklch(0.48 0.035 245);
|
||||
--accent: oklch(0.88 0.13 82);
|
||||
--accent-foreground: oklch(0.24 0.08 245);
|
||||
--destructive: oklch(0.60 0.21 25);
|
||||
--border: oklch(0.88 0.018 245);
|
||||
--input: oklch(0.90 0.018 245);
|
||||
--ring: oklch(0.47 0.16 245);
|
||||
--chart-1: oklch(0.47 0.16 245);
|
||||
--chart-2: oklch(0.66 0.20 32);
|
||||
--chart-3: oklch(0.82 0.15 82);
|
||||
--chart-4: oklch(0.58 0.15 205);
|
||||
--chart-5: oklch(0.55 0.16 25);
|
||||
--radius: 0.875rem;
|
||||
--sidebar: oklch(0.97 0.015 245);
|
||||
--sidebar-foreground: oklch(0.22 0.045 245);
|
||||
--sidebar-primary: oklch(0.47 0.16 245);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.93 0.04 245);
|
||||
--sidebar-accent-foreground: oklch(0.22 0.045 245);
|
||||
--sidebar-border: oklch(0.86 0.025 245);
|
||||
--sidebar-ring: oklch(0.47 0.16 245);
|
||||
/* Default theme tokens live in src/styles/themes.css under [data-theme="crisp"].
|
||||
Keep :root as a safety fallback for the brief moment before the inline theme
|
||||
script runs (prevents FOUC). */
|
||||
:root {
|
||||
--background: oklch(0.985 0.01 240);
|
||||
--foreground: oklch(0.20 0.045 240);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.20 0.045 240);
|
||||
--primary: oklch(0.62 0.13 240);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--accent: oklch(0.81 0.14 75);
|
||||
--destructive: oklch(0.62 0.21 25);
|
||||
--border: oklch(0.90 0.018 240);
|
||||
--ring: oklch(0.62 0.13 240);
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
.warm {
|
||||
|
|
|
|||
|
|
@ -1,63 +1,167 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import {
|
||||
Geist,
|
||||
Geist_Mono,
|
||||
Inter,
|
||||
Space_Grotesk,
|
||||
DM_Sans,
|
||||
Playfair_Display,
|
||||
Lato,
|
||||
Nunito,
|
||||
Outfit,
|
||||
JetBrains_Mono,
|
||||
} from "next/font/google";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { AuthProvider } from "@/components/auth-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: "#E85D4C",
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "WFDASH - Business CRM",
|
||||
description: "Business CRM for managing customers, companies, suppliers, quotes, and orders",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "WFDASH",
|
||||
},
|
||||
icons: {
|
||||
icon: "/icons/icon.svg",
|
||||
apple: "/icons/icon.svg",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<ServiceWorkerRegistration />
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { AuthProvider } from "@/components/auth-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { ThemeBootstrapScript } from "@/components/theme-bootstrap";
|
||||
|
||||
// Geist (default app fonts)
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
// Theme-bound fonts (registered as CSS vars for the theme picker to swap)
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
variable: "--font-space-grotesk",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
const dmSans = DM_Sans({
|
||||
variable: "--font-dm-sans",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
const playfair = Playfair_Display({
|
||||
variable: "--font-playfair",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
const lato = Lato({
|
||||
variable: "--font-lato",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "700"],
|
||||
display: "swap",
|
||||
});
|
||||
const nunito = Nunito({
|
||||
variable: "--font-nunito",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
const outfit = Outfit({
|
||||
variable: "--font-outfit",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
variable: "--font-jetbrains-mono",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: "#4C96D7",
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "WFDASH - Business CRM",
|
||||
description: "Business CRM for managing customers, companies, suppliers, quotes, and orders",
|
||||
manifest: "/manifest.json",
|
||||
applicationName: "WFDASH",
|
||||
authors: [{ name: "WFDASH" }],
|
||||
keywords: ["CRM", "Business", "Quotes", "Orders", "Customers", "Suppliers"],
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "WFDASH",
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico", sizes: "any" },
|
||||
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||
{ url: "/favicon-96x96.png", sizes: "96x96", type: "image/png" },
|
||||
{ url: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
|
||||
{ url: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
title: "WFDASH - Business CRM",
|
||||
description: "Business CRM for managing customers, companies, suppliers, quotes, and orders",
|
||||
siteName: "WFDASH",
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "WFDASH - Business CRM",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "WFDASH - Business CRM",
|
||||
description: "Business CRM for managing customers, companies, suppliers, quotes, and orders",
|
||||
images: ["/twitter-image.png"],
|
||||
},
|
||||
};
|
||||
|
||||
// All theme-bound CSS vars must be present on <html> so themes can swap them
|
||||
const FONT_VARS = [
|
||||
geistSans.variable,
|
||||
geistMono.variable,
|
||||
inter.variable,
|
||||
spaceGrotesk.variable,
|
||||
dmSans.variable,
|
||||
playfair.variable,
|
||||
lato.variable,
|
||||
nunito.variable,
|
||||
outfit.variable,
|
||||
jetbrainsMono.variable,
|
||||
].join(" ");
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${FONT_VARS} h-full antialiased`}
|
||||
data-theme="crisp"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
{/* Apply persisted theme before hydration to prevent flash of wrong theme */}
|
||||
<ThemeBootstrapScript />
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<ServiceWorkerRegistration />
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
173
src/components/charts/area-chart.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { withAlpha } from "./palette";
|
||||
|
||||
export type AreaSeries = {
|
||||
label: string;
|
||||
color: string;
|
||||
data: number[];
|
||||
};
|
||||
|
||||
export type AreaChartProps = {
|
||||
labels: string[];
|
||||
series: AreaSeries[];
|
||||
height?: number;
|
||||
/** Show legend below the chart */
|
||||
showLegend?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Multi-series area chart with gradient fills under each line.
|
||||
* Pure SVG — no external deps.
|
||||
*/
|
||||
export function AreaChart({
|
||||
labels,
|
||||
series,
|
||||
height = 220,
|
||||
showLegend = true,
|
||||
}: AreaChartProps) {
|
||||
const uid = useId();
|
||||
if (series.length === 0 || labels.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-sm text-muted-foreground"
|
||||
style={{ height }}
|
||||
>
|
||||
No data yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const w = 800;
|
||||
const h = height;
|
||||
const padX = 8;
|
||||
const padTop = 12;
|
||||
const padBottom = 28;
|
||||
const innerW = w - padX * 2;
|
||||
const innerH = h - padTop - padBottom;
|
||||
const allValues = series.flatMap((s) => s.data);
|
||||
const maxValue = Math.max(...allValues, 1);
|
||||
const step = labels.length > 1 ? innerW / (labels.length - 1) : innerW;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
className="w-full"
|
||||
style={{ height }}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
{series.map((s, i) => (
|
||||
<linearGradient
|
||||
key={i}
|
||||
id={`area-grad-${uid}-${i}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={s.color} stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor={s.color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{/* Horizontal grid lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((p, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={padX}
|
||||
x2={padX + innerW}
|
||||
y1={padTop + innerH * (1 - p)}
|
||||
y2={padTop + innerH * (1 - p)}
|
||||
stroke={withAlpha("#94A3B8", 8)}
|
||||
strokeWidth={1}
|
||||
strokeDasharray={p === 0 ? undefined : "3 3"}
|
||||
/>
|
||||
))}
|
||||
{/* Series lines + area fills */}
|
||||
{series.map((s, si) => {
|
||||
const pts = s.data.map((v, i) => {
|
||||
const x = padX + i * step;
|
||||
const y = padTop + innerH - (v / maxValue) * innerH;
|
||||
return { x, y, v, label: labels[i] };
|
||||
});
|
||||
const linePath = pts
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)},${p.y.toFixed(2)}`)
|
||||
.join(" ");
|
||||
const areaPath = `${linePath} L${pts[pts.length - 1].x.toFixed(2)},${(padTop + innerH).toFixed(2)} L${pts[0].x.toFixed(2)},${(padTop + innerH).toFixed(2)} Z`;
|
||||
return (
|
||||
<g key={si}>
|
||||
<path
|
||||
d={areaPath}
|
||||
fill={`url(#area-grad-${uid}-${si})`}
|
||||
/>
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={2.25}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
style={{ filter: `drop-shadow(0 0 6px ${withAlpha(s.color, 45)})` }}
|
||||
/>
|
||||
{pts.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={i === pts.length - 1 ? 3.5 : 0}
|
||||
fill={s.color}
|
||||
stroke="#0B1220"
|
||||
strokeWidth={i === pts.length - 1 ? 1.5 : 0}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* X-axis labels (first / mid / last) */}
|
||||
{(() => {
|
||||
const positions =
|
||||
labels.length <= 3
|
||||
? labels.map((_, i) => i)
|
||||
: [0, Math.floor(labels.length / 2), labels.length - 1];
|
||||
return positions.map((i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={padX + i * step}
|
||||
y={h - 8}
|
||||
textAnchor={i === 0 ? "start" : i === labels.length - 1 ? "end" : "middle"}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fill: "var(--muted-foreground)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{labels[i]}
|
||||
</text>
|
||||
));
|
||||
})()}
|
||||
</svg>
|
||||
{showLegend && (
|
||||
<div className="flex flex-wrap gap-3 mt-3 text-xs">
|
||||
{series.map((s, i) => {
|
||||
const last = s.data[s.data.length - 1] ?? 0;
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{
|
||||
background: s.color,
|
||||
boxShadow: `0 0 6px ${withAlpha(s.color, 60)}`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-muted-foreground">{s.label}</span>
|
||||
<span className="font-semibold tabular-nums">{last}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
src/components/charts/bar-chart.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import { withAlpha } from "./palette";
|
||||
|
||||
/**
|
||||
* BarChart — vertical bars, optional series stacking.
|
||||
* Single-series: bars get gradient of the color.
|
||||
* Multi-series: bars are stacked, each segment in its own color.
|
||||
*/
|
||||
export type BarSeries = {
|
||||
label: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type BarDatum = {
|
||||
/** X-axis label (e.g., "Mon", "W22") */
|
||||
label: string;
|
||||
/** Numeric values, one per series (in order). Empty array = empty bucket. */
|
||||
values: number[];
|
||||
};
|
||||
|
||||
export function BarChart({
|
||||
data,
|
||||
series,
|
||||
height = 180,
|
||||
}: {
|
||||
data: BarDatum[];
|
||||
series: BarSeries[];
|
||||
height?: number;
|
||||
}) {
|
||||
if (data.length === 0 || series.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-sm text-muted-foreground"
|
||||
style={{ height }}
|
||||
>
|
||||
No data yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const totals = data.map((d) => d.values.reduce((s, v) => s + v, 0));
|
||||
const maxValue = Math.max(...totals, 1);
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-1.5" style={{ height }}>
|
||||
{data.map((d, i) => {
|
||||
const total = totals[i];
|
||||
const totalH = maxValue === 0 ? 0 : (total / maxValue) * height;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 flex flex-col items-stretch justify-end group relative"
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
{/* Stacked segments from bottom up */}
|
||||
<div
|
||||
className="w-full rounded-md overflow-hidden flex flex-col-reverse"
|
||||
style={{ height: Math.max(totalH, 4) }}
|
||||
title={`${d.label}: ${total}`}
|
||||
>
|
||||
{d.values.map((v, si) => {
|
||||
const segH = total === 0 ? 0 : (v / total) * 100;
|
||||
return (
|
||||
<div
|
||||
key={si}
|
||||
style={{
|
||||
height: `${segH}%`,
|
||||
background: series[si].color,
|
||||
boxShadow: `0 0 12px ${withAlpha(series[si].color, 35)}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Hover tooltip */}
|
||||
{total > 0 && (
|
||||
<div
|
||||
className="absolute -top-7 left-1/2 -translate-x-1/2 text-[10px] font-semibold opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: "rgba(11, 18, 32, 0.92)",
|
||||
color: "#E6ECF5",
|
||||
border: "1px solid rgba(76,150,215,0.2)",
|
||||
}}
|
||||
>
|
||||
{total}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
{data.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 text-center text-[10px] font-medium text-muted-foreground truncate"
|
||||
>
|
||||
{d.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
src/components/charts/concentric-rings.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { withAlpha } from "./palette";
|
||||
|
||||
export type RingDatum = {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* ConcentricRings — multiple nested donut rings, each showing a different metric.
|
||||
* Inner-most ring = smallest, outer-most = largest.
|
||||
* Center shows total or primary value.
|
||||
*/
|
||||
export function ConcentricRings({
|
||||
rings,
|
||||
total,
|
||||
centerLabel,
|
||||
centerSubLabel,
|
||||
size = 180,
|
||||
}: {
|
||||
rings: RingDatum[];
|
||||
total: number;
|
||||
centerLabel: string;
|
||||
centerSubLabel?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const minR = size * 0.18;
|
||||
const maxR = size * 0.42;
|
||||
const stroke = size * 0.07;
|
||||
const gradId = useId();
|
||||
|
||||
if (total === 0 || rings.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-sm text-muted-foreground"
|
||||
style={{ height: size }}
|
||||
>
|
||||
No data yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Each ring uses a fraction of its radius — share of total
|
||||
const ringGap = (maxR - minR) / Math.max(rings.length, 1);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="relative shrink-0">
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<defs>
|
||||
{rings.map((_, i) => (
|
||||
<filter
|
||||
key={i}
|
||||
id={`ring-glow-${gradId}-${i}`}
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="1.5" />
|
||||
</filter>
|
||||
))}
|
||||
</defs>
|
||||
{rings.map((r, i) => {
|
||||
const radius = maxR - i * ringGap;
|
||||
const fraction = total === 0 ? 0 : r.value / total;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
return (
|
||||
<g key={i}>
|
||||
{/* Background track */}
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={withAlpha("#94A3B8", 14)}
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
{/* Active arc */}
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={r.color}
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={`${fraction * circumference} ${circumference}`}
|
||||
transform={`rotate(-90 ${cx} ${cy})`}
|
||||
strokeLinecap="round"
|
||||
style={{
|
||||
filter: `drop-shadow(0 0 6px ${withAlpha(r.color, 60)})`,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* Center label */}
|
||||
<text
|
||||
x={cx}
|
||||
y={centerSubLabel ? cy - 6 : cy + 6}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: size * 0.18,
|
||||
fontFamily: "var(--font-jetbrains-mono), monospace",
|
||||
fontWeight: 700,
|
||||
fill: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{centerLabel}
|
||||
</text>
|
||||
{centerSubLabel && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy + 14}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: size * 0.07,
|
||||
letterSpacing: 1.4,
|
||||
textTransform: "uppercase",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{centerSubLabel}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="flex-1 w-full space-y-1.5 text-xs">
|
||||
{rings.map((r, i) => {
|
||||
const fraction = total === 0 ? 0 : r.value / total;
|
||||
return (
|
||||
<div key={i} className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full shrink-0"
|
||||
style={{
|
||||
background: r.color,
|
||||
boxShadow: `0 0 8px ${withAlpha(r.color, 50)}`,
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{r.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="font-semibold tabular-nums">{r.value}</span>
|
||||
<span className="text-muted-foreground tabular-nums w-9 text-right">
|
||||
{Math.round(fraction * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/components/charts/dashboard-card.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { withAlpha } from "./palette";
|
||||
|
||||
/**
|
||||
* DashboardCard — frosted-glass frame used everywhere on the dashboard.
|
||||
* Adapts to active theme via CSS vars for surface/border; uses a subtle
|
||||
* colored top accent strip passed via `accentColor` for category coding.
|
||||
*/
|
||||
export function DashboardCard({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
accentColor,
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
padding = "p-5",
|
||||
}: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
icon?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
/** Thin colored strip at the top edge — uses the expanded chart palette */
|
||||
accentColor?: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
padding?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-xl border overflow-hidden",
|
||||
padding,
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
background: "var(--card)",
|
||||
borderColor: withAlpha(accentColor ?? "#4C96D7", 18),
|
||||
boxShadow:
|
||||
"0 1px 0 color-mix(in oklch, var(--foreground) 4%, transparent) inset, 0 8px 24px rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
{accentColor && (
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-0.5"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, ${accentColor}, ${withAlpha(accentColor, 25)})`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(title || action) && (
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{Icon && accentColor && (
|
||||
<div
|
||||
className="grid h-7 w-7 place-items-center rounded-md shrink-0"
|
||||
style={{
|
||||
background: withAlpha(accentColor, 14),
|
||||
color: accentColor,
|
||||
}}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" style={{ color: accentColor }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{title && (
|
||||
<div className="text-sm font-semibold truncate">{title}</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
src/components/charts/donut-chart.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { withAlpha } from "./palette";
|
||||
|
||||
export type DonutBucket = {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export function DonutChart({
|
||||
buckets,
|
||||
total,
|
||||
centerLabel,
|
||||
centerSubLabel,
|
||||
size = 180,
|
||||
}: {
|
||||
buckets: DonutBucket[];
|
||||
total: number;
|
||||
centerLabel: string;
|
||||
centerSubLabel?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = size * 0.36;
|
||||
const stroke = size * 0.13;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const gradId = useId();
|
||||
|
||||
if (total === 0 || buckets.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-sm text-muted-foreground"
|
||||
style={{ height: size }}
|
||||
>
|
||||
No data yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build segments with running cumulative offset via reduce (lint-friendly)
|
||||
const segments = buckets.reduce<
|
||||
Array<DonutBucket & { dash: number; offset: number; fraction: number }>
|
||||
>((acc, b) => {
|
||||
const priorTotal = acc.reduce((s, x) => s + x.fraction, 0);
|
||||
const fraction = b.value / total;
|
||||
acc.push({
|
||||
...b,
|
||||
dash: fraction * circumference,
|
||||
offset: -priorTotal * circumference,
|
||||
fraction,
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="relative shrink-0">
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<defs>
|
||||
{segments.map((s, i) => (
|
||||
<filter
|
||||
key={i}
|
||||
id={`glow-${gradId}-${i}`}
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="2.5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
))}
|
||||
</defs>
|
||||
{/* Background ring */}
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={withAlpha("#94A3B8", 14)}
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
{segments.map((s, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={`${s.dash} ${circumference}`}
|
||||
strokeDashoffset={s.offset}
|
||||
transform={`rotate(-90 ${cx} ${cy})`}
|
||||
strokeLinecap="butt"
|
||||
filter={`url(#glow-${gradId}-${i})`}
|
||||
/>
|
||||
))}
|
||||
{/* Center label */}
|
||||
<text
|
||||
x={cx}
|
||||
y={centerSubLabel ? cy - 6 : cy + 6}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: size * 0.16,
|
||||
fontFamily: "var(--font-heading)",
|
||||
fontWeight: 700,
|
||||
fill: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{centerLabel}
|
||||
</text>
|
||||
{centerSubLabel && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy + 14}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: size * 0.07,
|
||||
letterSpacing: 1.4,
|
||||
textTransform: "uppercase",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{centerSubLabel}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="flex-1 w-full space-y-1.5 text-xs">
|
||||
{segments.map((s, i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full shrink-0"
|
||||
style={{
|
||||
background: s.color,
|
||||
boxShadow: `0 0 8px ${withAlpha(s.color, 50)}`,
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{s.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="font-semibold tabular-nums">{s.value}</span>
|
||||
<span className="text-muted-foreground tabular-nums w-9 text-right">
|
||||
{Math.round(s.fraction * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
src/components/charts/horizontal-bars.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import { withAlpha } from "./palette";
|
||||
|
||||
export type HBarDatum = {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* HorizontalBar — for "top customers", ranked lists, etc.
|
||||
* Each row is a full-width rounded bar with a label, value, and percentage fill.
|
||||
*/
|
||||
export function HorizontalBars({
|
||||
data,
|
||||
unit = "",
|
||||
maxValue,
|
||||
}: {
|
||||
data: HBarDatum[];
|
||||
unit?: string;
|
||||
/** Optional explicit max; otherwise computed from data */
|
||||
maxValue?: number;
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No data yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const max = maxValue ?? Math.max(...data.map((d) => d.value), 1);
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{data.map((d, i) => {
|
||||
const pct = (d.value / max) * 100;
|
||||
const color = d.color ?? "#4C96D7";
|
||||
return (
|
||||
<div key={i} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="font-medium truncate">{d.label}</span>
|
||||
<span className="font-bold tabular-nums shrink-0">
|
||||
{d.value.toLocaleString()}
|
||||
{unit}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-2 w-full rounded-full overflow-hidden"
|
||||
style={{ background: withAlpha(color, 12) }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
background: `linear-gradient(90deg, ${color}, ${withAlpha(color, 70)})`,
|
||||
boxShadow: `0 0 8px ${withAlpha(color, 50)}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
src/components/charts/multi-line-chart.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"use client";
|
||||
|
||||
import { withAlpha } from "./palette";
|
||||
|
||||
export type LineSeries = {
|
||||
label: string;
|
||||
color: string;
|
||||
data: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* MultiLineChart — pure line chart with circular dots at endpoints.
|
||||
* No area fill — just crisp glowing lines + dots.
|
||||
* Like the multi-peak chart in the reference with circular endpoints.
|
||||
*/
|
||||
export function MultiLineChart({
|
||||
labels,
|
||||
series,
|
||||
height = 180,
|
||||
}: {
|
||||
labels: string[];
|
||||
series: LineSeries[];
|
||||
height?: number;
|
||||
}) {
|
||||
if (series.length === 0 || labels.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-sm text-muted-foreground"
|
||||
style={{ height }}
|
||||
>
|
||||
No data yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const w = 600;
|
||||
const padX = 8;
|
||||
const padTop = 8;
|
||||
const padBottom = 24;
|
||||
const innerW = w - padX * 2;
|
||||
const innerH = height - padTop - padBottom;
|
||||
const allValues = series.flatMap((s) => s.data);
|
||||
const maxValue = Math.max(...allValues, 1);
|
||||
const step = labels.length > 1 ? innerW / (labels.length - 1) : innerW;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${height}`}
|
||||
className="w-full"
|
||||
style={{ height }}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{series.map((s, si) => {
|
||||
const pts = s.data.map((v, i) => {
|
||||
const x = padX + i * step;
|
||||
const y = padTop + innerH - (v / maxValue) * innerH;
|
||||
return { x, y };
|
||||
});
|
||||
// Smooth curve using Catmull-Rom -> Bezier conversion (simplified)
|
||||
const linePath = pts
|
||||
.map((p, i) => {
|
||||
if (i === 0) return `M${p.x.toFixed(2)},${p.y.toFixed(2)}`;
|
||||
const prev = pts[i - 1];
|
||||
const cpx = (prev.x + p.x) / 2;
|
||||
return `Q${cpx.toFixed(2)},${prev.y.toFixed(2)} ${cpx.toFixed(2)},${((prev.y + p.y) / 2).toFixed(2)} T${p.x.toFixed(2)},${p.y.toFixed(2)}`;
|
||||
})
|
||||
.join(" ");
|
||||
return (
|
||||
<g key={si}>
|
||||
{/* Glow layer */}
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity={0.3}
|
||||
style={{ filter: `blur(3px)` }}
|
||||
/>
|
||||
{/* Crisp line */}
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={2.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Dots: every data point */}
|
||||
{pts.map((p, i) => (
|
||||
<g key={i}>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={4.5}
|
||||
fill="#0B1220"
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
style={{
|
||||
filter: `drop-shadow(0 0 4px ${withAlpha(s.color, 60)})`,
|
||||
}}
|
||||
/>
|
||||
<circle cx={p.x} cy={p.y} r={1.5} fill={s.color} />
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* X-axis labels (first / mid / last) */}
|
||||
{(() => {
|
||||
const positions =
|
||||
labels.length <= 3
|
||||
? labels.map((_, i) => i)
|
||||
: [0, Math.floor(labels.length / 2), labels.length - 1];
|
||||
return positions.map((i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={padX + i * step}
|
||||
y={height - 6}
|
||||
textAnchor={
|
||||
i === 0 ? "start" : i === labels.length - 1 ? "end" : "middle"
|
||||
}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fill: "var(--muted-foreground)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{labels[i]}
|
||||
</text>
|
||||
));
|
||||
})()}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
42
src/components/charts/palette.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Brand + dashboard chart color palette.
|
||||
*
|
||||
* Brand colors (locked, used everywhere):
|
||||
* blue #4C96D7
|
||||
* gold #F2AF58
|
||||
* red #E94E49
|
||||
*
|
||||
* Expanded data palette (dashboard charts only):
|
||||
* cyan / pink / orange / green / purple
|
||||
*
|
||||
* The expanded colors are intentionally brighter + more saturated so they
|
||||
* read well on dark surfaces and pop in chart contexts. They are NOT used
|
||||
* outside the dashboard — sidebar buttons, brand surfaces, theme tokens
|
||||
* stay locked to the 3 brand colors.
|
||||
*/
|
||||
export const BRAND = {
|
||||
blue: "#4C96D7",
|
||||
gold: "#F2AF58",
|
||||
red: "#E94E49",
|
||||
} as const;
|
||||
|
||||
export const CHART = {
|
||||
...BRAND,
|
||||
cyan: "#22D3EE",
|
||||
pink: "#EC4899",
|
||||
orange: "#F97316",
|
||||
yellow: "#FBBF24",
|
||||
green: "#10B981",
|
||||
purple: "#A78BFA",
|
||||
muted: "#94A3B8",
|
||||
} as const;
|
||||
|
||||
/** 8-digit hex with alpha (browser-portable, no color-mix needed) */
|
||||
export function withAlpha(hex: string, alphaPercent: number): string {
|
||||
const a = Math.round((alphaPercent / 100) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `${hex}${a}`;
|
||||
}
|
||||
47
src/components/charts/progress-bar.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { withAlpha } from "./palette";
|
||||
|
||||
/**
|
||||
* ProgressBar — vertical or horizontal bar showing a percentage fill.
|
||||
* Used for compact status widgets, e.g. "Approval Pending: 62%".
|
||||
*/
|
||||
export function ProgressBar({
|
||||
label,
|
||||
value,
|
||||
total,
|
||||
color,
|
||||
unit = "",
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
total: number;
|
||||
color: string;
|
||||
unit?: string;
|
||||
}) {
|
||||
const pct = total === 0 ? 0 : Math.round((value / total) * 100);
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="font-medium truncate">{label}</span>
|
||||
<span className="text-muted-foreground tabular-nums shrink-0">
|
||||
<span className="font-semibold text-foreground">{value}</span>
|
||||
{unit ? ` ${unit}` : ""} · {pct}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-2 w-full rounded-full overflow-hidden"
|
||||
style={{ background: withAlpha(color, 12) }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
background: `linear-gradient(90deg, ${color}, ${withAlpha(color, 60)})`,
|
||||
boxShadow: `0 0 8px ${withAlpha(color, 50)}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
src/components/charts/radial-gauge.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { withAlpha } from "./palette";
|
||||
|
||||
/**
|
||||
* RadialGauge — half-donut (180°) with tick marks around the arc and a
|
||||
* central percentage. Like a speedometer.
|
||||
*
|
||||
* Reference: matches the "DASHBOARD ELEMENT" gauge in the design kit
|
||||
* with radial tick marks.
|
||||
*/
|
||||
export function RadialGauge({
|
||||
value,
|
||||
total,
|
||||
color,
|
||||
label,
|
||||
sublabel,
|
||||
size = 180,
|
||||
ticks = 24,
|
||||
}: {
|
||||
value: number;
|
||||
total: number;
|
||||
color: string;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
size?: number;
|
||||
ticks?: number;
|
||||
}) {
|
||||
const cx = size / 2;
|
||||
const cy = size * 0.62;
|
||||
const r = size * 0.36;
|
||||
const stroke = size * 0.1;
|
||||
const circumference = Math.PI * r; // half-circle
|
||||
const fraction = total === 0 ? 0 : Math.min(1, value / total);
|
||||
const gradId = useId();
|
||||
|
||||
// Tick marks around the half-circle
|
||||
const tickElements: React.ReactNode[] = [];
|
||||
for (let i = 0; i <= ticks; i++) {
|
||||
const angle = -180 + (i / ticks) * 180; // -180° to 0°
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
const outerR = r + stroke / 2 + 4;
|
||||
const innerR = r + stroke / 2 + 1;
|
||||
const isMajor = i % 4 === 0;
|
||||
const x1 = cx + Math.cos(rad) * outerR;
|
||||
const y1 = cy + Math.sin(rad) * outerR;
|
||||
const x2 = cx + Math.cos(rad) * innerR;
|
||||
const y2 = cy + Math.sin(rad) * innerR;
|
||||
tickElements.push(
|
||||
<line
|
||||
key={i}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={withAlpha("#94A3B8", isMajor ? 50 : 25)}
|
||||
strokeWidth={isMajor ? 1.5 : 1}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Cap dot at the end of the value arc
|
||||
const valueAngle = -180 + fraction * 180;
|
||||
const valueRad = (valueAngle * Math.PI) / 180;
|
||||
const capX = cx + Math.cos(valueRad) * r;
|
||||
const capY = cy + Math.sin(valueRad) * r;
|
||||
|
||||
const pct = Math.round(fraction * 100);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="relative">
|
||||
<svg width={size} height={size * 0.72} viewBox={`0 0 ${size} ${size * 0.72}`}>
|
||||
<defs>
|
||||
<filter id={`gauge-glow-${gradId}`} x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="3" />
|
||||
</filter>
|
||||
</defs>
|
||||
{tickElements}
|
||||
{/* Background track */}
|
||||
<path
|
||||
d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none"
|
||||
stroke={withAlpha("#94A3B8", 14)}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Active arc */}
|
||||
<path
|
||||
d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${fraction * circumference} ${circumference}`}
|
||||
style={{
|
||||
filter: `drop-shadow(0 0 10px ${withAlpha(color, 60)})`,
|
||||
}}
|
||||
/>
|
||||
{/* Cap dot */}
|
||||
<circle
|
||||
cx={capX}
|
||||
cy={capY}
|
||||
r={stroke * 0.55}
|
||||
fill="#fff"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
style={{ filter: `drop-shadow(0 0 6px ${withAlpha(color, 70)})` }}
|
||||
/>
|
||||
{/* Center percentage */}
|
||||
<text
|
||||
x={cx}
|
||||
y={cy - 4}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: size * 0.22,
|
||||
fontFamily: "var(--font-jetbrains-mono), monospace",
|
||||
fontWeight: 700,
|
||||
fill: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{pct}%
|
||||
</text>
|
||||
{label && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy + 18}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: size * 0.07,
|
||||
letterSpacing: 1.4,
|
||||
textTransform: "uppercase",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
{sublabel && (
|
||||
<div className="text-xs text-muted-foreground -mt-1">{sublabel}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/components/charts/sparkline.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Sparkline — tiny inline-SVG line chart for KPI cards.
|
||||
* Includes gradient fill below the line + endpoint dot.
|
||||
*/
|
||||
export function Sparkline({
|
||||
values,
|
||||
color,
|
||||
width = 96,
|
||||
height = 36,
|
||||
}: {
|
||||
values: number[];
|
||||
color: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}) {
|
||||
if (values.length === 0) {
|
||||
return <div style={{ width, height }} aria-hidden />;
|
||||
}
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values, 0);
|
||||
const range = max - min || 1;
|
||||
const step = values.length > 1 ? width / (values.length - 1) : width;
|
||||
const points = values
|
||||
.map((v, i) => {
|
||||
const x = i * step;
|
||||
const y = height - ((v - min) / range) * height;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
})
|
||||
.join(" ");
|
||||
const areaPoints = `0,${height} ${points} ${width},${height}`;
|
||||
const id = `spark-${color.replace("#", "")}-${Math.round(width)}`;
|
||||
const last = values[values.length - 1];
|
||||
const lastX = (values.length - 1) * step;
|
||||
const lastY = height - ((last - min) / range) * height;
|
||||
return (
|
||||
<svg width={width} height={height} className="overflow-visible shrink-0">
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.45} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points={areaPoints} fill={`url(#${id})`} />
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx={lastX} cy={lastY} r={3.5} fill={color} />
|
||||
<circle cx={lastX} cy={lastY} r={6} fill={color} fillOpacity={0.25} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
777
src/components/custom-theme-modal.tsx
Normal file
|
|
@ -0,0 +1,777 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useId } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Check,
|
||||
AlertTriangle,
|
||||
Wand2,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Plus,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DEFAULT_CUSTOM_COLORS,
|
||||
autoFixForeground,
|
||||
deriveTokens,
|
||||
getContrastChecks,
|
||||
hexToRgb,
|
||||
loadCustomThemes,
|
||||
newCustomThemeId,
|
||||
presetToColors,
|
||||
saveActiveCustomThemeId,
|
||||
saveCustomThemes,
|
||||
type CustomTheme,
|
||||
type CustomThemeColors,
|
||||
} from "@/lib/custom-themes";
|
||||
import { THEMES } from "@/lib/themes";
|
||||
|
||||
/* ============================================================================
|
||||
* Color field metadata
|
||||
* ========================================================================== */
|
||||
const COLOR_FIELDS: {
|
||||
key: keyof CustomThemeColors;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ key: "primary", label: "Primary" },
|
||||
{ key: "accent", label: "Accent" },
|
||||
{ key: "destructive", label: "Destructive" },
|
||||
{ key: "background", label: "Background" },
|
||||
{ key: "sidebar", label: "Sidebar" },
|
||||
{ key: "foreground", label: "Foreground" },
|
||||
{ key: "border", label: "Border" },
|
||||
];
|
||||
|
||||
const FIELD_DESCRIPTIONS: Record<keyof CustomThemeColors, string> = {
|
||||
primary: "Buttons, links, active nav",
|
||||
accent: "Highlights, revenue, gold CTAs",
|
||||
destructive: "Errors, over-budget, delete",
|
||||
background: "Main page surface",
|
||||
sidebar: "Left navigation background",
|
||||
foreground: "Body text",
|
||||
border: "Dividers, card outlines",
|
||||
};
|
||||
|
||||
/* ============================================================================
|
||||
* Modal — sticky header + scrollable body + sticky footer
|
||||
*
|
||||
* Layout:
|
||||
* ┌─ Header (sticky top) ─────────────────────────────┐
|
||||
* ├─ Body (scrollable) ────────┬─ Side panel ─────────┤
|
||||
* │ Editor │ Preview │
|
||||
* │ • Preset chips │ Saved themes │
|
||||
* │ • Color grid (2 cols) │ │
|
||||
* │ • Contrast warnings │ │
|
||||
* │ • Name input │ │
|
||||
* ├─ Footer (sticky bottom) ──────────────────────────┤
|
||||
* │ [Reset] [Cancel] [Save theme] │
|
||||
* └────────────────────────────────────────────────────┘
|
||||
* ========================================================================== */
|
||||
export function CustomThemeModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingThemeId,
|
||||
onChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingThemeId?: string | null;
|
||||
onChange?: () => void;
|
||||
}) {
|
||||
const [colors, setColors] = useState<CustomThemeColors>(DEFAULT_CUSTOM_COLORS);
|
||||
const [name, setName] = useState("My theme");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<Record<string, string> | null>(null);
|
||||
const [existingThemes, setExistingThemes] = useState<CustomTheme[]>([]);
|
||||
const [presetsOpen, setPresetsOpen] = useState(false);
|
||||
|
||||
/* ---- Lifecycle: load existing + snapshot on open ---- */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const list = loadCustomThemes();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setExistingThemes(list);
|
||||
|
||||
const snapshotTokens = deriveTokensActive();
|
||||
setSnapshot(snapshotTokens);
|
||||
|
||||
if (editingThemeId) {
|
||||
const t = list.find((x) => x.id === editingThemeId);
|
||||
if (t) {
|
||||
setColors(t.colors);
|
||||
setName(t.name);
|
||||
setEditingId(t.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const activeId = localStorage.getItem("wfdash-active-custom-id");
|
||||
if (activeId) {
|
||||
const active = list.find((x) => x.id === activeId);
|
||||
if (active) {
|
||||
setColors(active.colors);
|
||||
setName(active.name);
|
||||
setEditingId(active.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setColors(DEFAULT_CUSTOM_COLORS);
|
||||
setName("My theme");
|
||||
setEditingId(null);
|
||||
}, [open, editingThemeId]);
|
||||
|
||||
/* ---- Live preview: apply tokens to <html> while editing ---- */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const tokens = deriveTokens(colors);
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(tokens)) {
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
root.setAttribute("data-theme", "custom");
|
||||
}, [colors, open]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
const root = document.documentElement;
|
||||
if (snapshot) {
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
} else {
|
||||
const keys = Object.keys(deriveTokens(DEFAULT_CUSTOM_COLORS));
|
||||
for (const k of keys) root.style.removeProperty(k);
|
||||
root.setAttribute("data-theme", "crisp");
|
||||
}
|
||||
onOpenChange(false);
|
||||
}, [snapshot, onOpenChange]);
|
||||
|
||||
const handleReset = () => {
|
||||
setColors(DEFAULT_CUSTOM_COLORS);
|
||||
setName("My theme");
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const id = editingId ?? newCustomThemeId();
|
||||
const theme: CustomTheme = {
|
||||
id,
|
||||
name: name.trim() || "Untitled theme",
|
||||
colors,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const next = [...existingThemes.filter((t) => t.id !== id), theme];
|
||||
saveCustomThemes(next);
|
||||
saveActiveCustomThemeId(id);
|
||||
setExistingThemes(next);
|
||||
setEditingId(id);
|
||||
onChange?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
const next = existingThemes.filter((t) => t.id !== id);
|
||||
saveCustomThemes(next);
|
||||
setExistingThemes(next);
|
||||
if (id === editingId) {
|
||||
setColors(DEFAULT_CUSTOM_COLORS);
|
||||
setName("My theme");
|
||||
setEditingId(null);
|
||||
}
|
||||
onChange?.();
|
||||
};
|
||||
|
||||
const handleLoadFromPreset = (presetId: string) => {
|
||||
const presetColors = presetToColors(presetId);
|
||||
if (presetColors) {
|
||||
setColors(presetColors);
|
||||
setPresetsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoFixContrast = () => {
|
||||
const checks = getContrastChecks(colors);
|
||||
const fixed = { ...colors };
|
||||
for (const c of checks) {
|
||||
if (!c.passes) {
|
||||
if (c.label.startsWith("Body text")) {
|
||||
fixed.foreground = autoFixForeground(colors.background);
|
||||
} else if (c.label.startsWith("Sidebar")) {
|
||||
fixed.sidebar = deriveBetterSidebar(fixed.sidebar);
|
||||
} else if (c.label.startsWith("Primary")) {
|
||||
fixed.primary = deriveBetterContrast(colors.primary);
|
||||
} else if (c.label.startsWith("Accent")) {
|
||||
fixed.accent = deriveBetterContrast(colors.accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
setColors(fixed);
|
||||
};
|
||||
|
||||
const contrastChecks = getContrastChecks(colors);
|
||||
const failedChecks = contrastChecks.filter((c) => !c.passes);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => (o ? onOpenChange(true) : handleCancel())}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="max-w-[calc(100%-1rem)] sm:max-w-xl md:max-w-3xl lg:max-w-4xl w-full p-0 gap-0 max-h-[min(90vh,800px)] flex flex-col overflow-hidden rounded-xl"
|
||||
>
|
||||
{/* ============ HEADER (sticky top) ============ */}
|
||||
<div className="px-5 sm:px-6 py-4 border-b shrink-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="grid h-7 w-7 place-items-center rounded-md bg-primary/10 text-primary shrink-0">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
Customize theme
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="mt-1 text-xs">
|
||||
Pick 7 colors. The rest is automatic.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
aria-label="Close"
|
||||
className="grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors shrink-0 -mt-1 -mr-1"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ BODY (scrollable middle) ============ */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_240px] min-h-0">
|
||||
{/* EDITOR */}
|
||||
<div className="p-5 sm:p-6 space-y-5 min-w-0">
|
||||
{/* Presets — collapsed by default */}
|
||||
<details
|
||||
open={presetsOpen}
|
||||
onToggle={(e) => setPresetsOpen((e.target as HTMLDetailsElement).open)}
|
||||
className="group rounded-lg border bg-card/40"
|
||||
>
|
||||
<summary className="flex items-center justify-between cursor-pointer px-3 py-2 list-none select-none [&::-webkit-details-marker]:hidden">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Start from a preset
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 text-muted-foreground transition-transform",
|
||||
presetsOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</summary>
|
||||
<div className="px-3 pb-3 pt-1">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{THEMES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => handleLoadFromPreset(t.id)}
|
||||
className="px-2 py-1 rounded-md border text-[11px] font-medium hover:border-primary/40 hover:bg-accent transition-colors"
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Color pickers — 2-column grid at sm+ */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Colors
|
||||
</div>
|
||||
{failedChecks.length > 0 && (
|
||||
<div
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold"
|
||||
style={{
|
||||
color: "var(--destructive)",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{failedChecks.length} issue{failedChecks.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
|
||||
{COLOR_FIELDS.map((f) => (
|
||||
<ColorRow
|
||||
key={f.key}
|
||||
fieldKey={f.key}
|
||||
label={f.label}
|
||||
value={colors[f.key]}
|
||||
onChange={(hex) =>
|
||||
setColors((c) => ({ ...c, [f.key]: hex }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contrast warnings — compact inline */}
|
||||
{failedChecks.length > 0 && (
|
||||
<div
|
||||
className="rounded-lg border p-3 space-y-2"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in oklch, var(--destructive) 30%, transparent)",
|
||||
background:
|
||||
"color-mix(in oklch, var(--destructive) 5%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: "var(--destructive)" }}
|
||||
>
|
||||
Contrast below WCAG AA
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleAutoFixContrast}
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
>
|
||||
<Wand2 className="h-3 w-3" />
|
||||
Auto-fix
|
||||
</Button>
|
||||
</div>
|
||||
<ul
|
||||
className="text-[11px] space-y-0.5"
|
||||
style={{ color: "var(--destructive)" }}
|
||||
>
|
||||
{failedChecks.map((c) => (
|
||||
<li
|
||||
key={c.label}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span>{c.label}</span>
|
||||
<span className="font-mono tabular-nums opacity-80">
|
||||
{c.ratio.toFixed(1)}:1
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme name */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="theme-name"
|
||||
className="text-xs font-semibold uppercase tracking-widest text-muted-foreground"
|
||||
>
|
||||
Theme name
|
||||
</label>
|
||||
<Input
|
||||
id="theme-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My theme"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SIDE PANEL — sticky on md+ */}
|
||||
<div
|
||||
className="md:sticky md:top-0 md:self-start md:max-h-[calc(90vh-200px)] p-5 space-y-5 border-t md:border-t-0 md:border-l bg-muted/30"
|
||||
>
|
||||
{/* Live preview */}
|
||||
<div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
|
||||
Preview
|
||||
</div>
|
||||
<PreviewMock colors={colors} />
|
||||
</div>
|
||||
|
||||
{/* Saved themes */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
Saved themes
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{existingThemes.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{existingThemes.length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground py-3 text-center border border-dashed rounded-md">
|
||||
No saved themes yet
|
||||
</div>
|
||||
) : (
|
||||
existingThemes.map((t) => (
|
||||
<SavedThemeRow
|
||||
key={t.id}
|
||||
theme={t}
|
||||
active={editingId === t.id}
|
||||
onSelect={() => {
|
||||
setColors(t.colors);
|
||||
setName(t.name);
|
||||
setEditingId(t.id);
|
||||
}}
|
||||
onDelete={() => handleDelete(t.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setColors(DEFAULT_CUSTOM_COLORS);
|
||||
setName("New theme");
|
||||
setEditingId(null);
|
||||
}}
|
||||
className="w-full mt-2 gap-1.5 h-8 text-xs"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ FOOTER (sticky bottom) ============ */}
|
||||
<div className="px-5 sm:px-6 py-3 border-t shrink-0 bg-muted/20 flex items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{editingId ? "Update theme" : "Save theme"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Helpers — auto-fix math
|
||||
* ========================================================================== */
|
||||
function deriveBetterSidebar(current: string): string {
|
||||
const rgb = hexToRgb(current);
|
||||
if (!rgb) return current;
|
||||
const factor = 0.7;
|
||||
const r = Math.round(rgb[0] * factor);
|
||||
const g = Math.round(rgb[1] * factor);
|
||||
const b = Math.round(rgb[2] * factor);
|
||||
const toHex = (n: number) => n.toString(16).padStart(2, "0");
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
function deriveBetterContrast(current: string): string {
|
||||
const rgb = hexToRgb(current);
|
||||
if (!rgb) return current;
|
||||
const lum =
|
||||
(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
|
||||
const target = 0.45;
|
||||
const factor =
|
||||
lum > 0.5 ? target / lum : Math.min(1, (1 - lum) / (1 - target));
|
||||
const safeFactor = Math.max(0.5, Math.min(0.95, factor));
|
||||
const r = Math.round(rgb[0] * safeFactor);
|
||||
const g = Math.round(rgb[1] * safeFactor);
|
||||
const b = Math.round(rgb[2] * safeFactor);
|
||||
const toHex = (n: number) => n.toString(16).padStart(2, "0");
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
function deriveTokensActive(): Record<string, string> | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const root = document.documentElement;
|
||||
const inlineStyle = root.style;
|
||||
const keys = Object.keys(deriveTokens(DEFAULT_CUSTOM_COLORS));
|
||||
const out: Record<string, string> = {};
|
||||
let any = false;
|
||||
for (const k of keys) {
|
||||
const v = inlineStyle.getPropertyValue(k);
|
||||
if (v) {
|
||||
out[k] = v;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
return any ? out : null;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* ColorRow — compact 1-line: swatch + label + hex input
|
||||
* ========================================================================== */
|
||||
function ColorRow({
|
||||
fieldKey,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
fieldKey: keyof CustomThemeColors;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (hex: string) => void;
|
||||
}) {
|
||||
const reactId = useId();
|
||||
const inputId = `${reactId}-${fieldKey}`;
|
||||
const [text, setText] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setText(value);
|
||||
}, [value]);
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const next = e.target.value;
|
||||
setText(next);
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(next)) onChange(next);
|
||||
};
|
||||
|
||||
const handleNativeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 py-1">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
title={FIELD_DESCRIPTIONS[fieldKey]}
|
||||
className="relative h-8 w-8 shrink-0 rounded-md overflow-hidden border cursor-pointer shadow-sm ring-1 ring-black/5"
|
||||
style={{ background: value }}
|
||||
>
|
||||
<input
|
||||
id={inputId}
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={handleNativeChange}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
aria-label={`${label} color`}
|
||||
/>
|
||||
</label>
|
||||
<span
|
||||
className="text-sm font-medium flex-1 truncate"
|
||||
title={FIELD_DESCRIPTIONS[fieldKey]}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={handleTextChange}
|
||||
spellCheck={false}
|
||||
aria-label={`${label} hex value`}
|
||||
className="w-[88px] h-8 px-2 text-[11px] font-mono rounded-md border bg-background text-foreground tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* SavedThemeRow — compact saved-theme entry in the side panel
|
||||
* ========================================================================== */
|
||||
function SavedThemeRow({
|
||||
theme,
|
||||
active,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: {
|
||||
theme: CustomTheme;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-2 p-1.5 rounded-md border text-xs transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:border-primary/40 hover:bg-background"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||
>
|
||||
<div className="flex gap-0.5 shrink-0">
|
||||
<span
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{ background: theme.colors.primary }}
|
||||
/>
|
||||
<span
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{ background: theme.colors.accent }}
|
||||
/>
|
||||
<span
|
||||
className="h-3 w-3 rounded-sm border"
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
borderColor: "rgba(0,0,0,.08)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="font-medium truncate flex-1">{theme.name}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
aria-label={`Delete ${theme.name}`}
|
||||
className="p-1 rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-all"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* PreviewMock — compact WFDASH mock using current colors
|
||||
* ========================================================================== */
|
||||
function PreviewMock({ colors }: { colors: CustomThemeColors }) {
|
||||
const tokens = deriveTokens(colors);
|
||||
const surfaceStyle = {
|
||||
background: tokens["--background"],
|
||||
color: tokens["--foreground"],
|
||||
borderColor: tokens["--border"],
|
||||
};
|
||||
const sidebarStyle = {
|
||||
background: tokens["--sidebar"],
|
||||
color: tokens["--sidebar-foreground"],
|
||||
};
|
||||
const primaryStyle = {
|
||||
background: tokens["--primary"],
|
||||
color: tokens["--primary-foreground"],
|
||||
};
|
||||
const accentStyle = {
|
||||
background: tokens["--accent"],
|
||||
color: tokens["--accent-foreground"],
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg overflow-hidden border text-xs shadow-sm"
|
||||
style={{
|
||||
...surfaceStyle,
|
||||
borderColor: tokens["--border"],
|
||||
}}
|
||||
>
|
||||
<div className="flex" style={{ height: 180 }}>
|
||||
<div className="w-12 p-2 space-y-1.5 shrink-0" style={sidebarStyle}>
|
||||
<div
|
||||
className="h-4 w-4 rounded grid place-items-center text-[8px] font-bold mb-1.5"
|
||||
style={primaryStyle}
|
||||
>
|
||||
W
|
||||
</div>
|
||||
{["Q", "C", "O"].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded"
|
||||
style={{
|
||||
background:
|
||||
i === 0 ? tokens["--primary"] : "rgba(255,255,255,0.15)",
|
||||
opacity: i === 0 ? 1 : 0.5,
|
||||
height: i === 0 ? 5 : 4,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 p-2 space-y-1.5 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="font-semibold text-[10px] truncate">Dashboard</div>
|
||||
<div
|
||||
className="px-1.5 py-0.5 rounded text-[8px] font-semibold shrink-0"
|
||||
style={primaryStyle}
|
||||
>
|
||||
+ New
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div
|
||||
className="p-1 rounded border"
|
||||
style={{ borderColor: tokens["--border"] }}
|
||||
>
|
||||
<div className="text-[7px] opacity-60 uppercase tracking-wider">
|
||||
Customers
|
||||
</div>
|
||||
<div className="text-[11px] font-bold leading-tight">248</div>
|
||||
</div>
|
||||
<div
|
||||
className="p-1 rounded border"
|
||||
style={{ borderColor: tokens["--border"] }}
|
||||
>
|
||||
<div className="text-[7px] opacity-60 uppercase tracking-wider">
|
||||
Quotes
|
||||
</div>
|
||||
<div className="text-[11px] font-bold leading-tight">14</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 pt-0.5">
|
||||
<div
|
||||
className="px-1.5 py-0.5 rounded text-[8px] font-semibold"
|
||||
style={primaryStyle}
|
||||
>
|
||||
Primary
|
||||
</div>
|
||||
<div
|
||||
className="px-1.5 py-0.5 rounded text-[8px] font-semibold"
|
||||
style={accentStyle}
|
||||
>
|
||||
Accent
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,7 +32,6 @@ import { Button } from "@/components/ui/button";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Loader2,
|
||||
Sparkles,
|
||||
|
|
@ -343,8 +342,8 @@ export default function RewriteQuoteModal({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl w-[calc(100%-2rem)] max-h-[90vh] p-0 gap-0 flex flex-col">
|
||||
<DialogHeader className="px-6 pt-5 pb-3">
|
||||
<DialogContent className="max-w-2xl md:max-w-3xl lg:max-w-4xl w-[calc(100%-2rem)] max-h-[90vh] p-0 gap-0 flex flex-col">
|
||||
<DialogHeader className="px-6 py-4 border-b shrink-0 space-y-1">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
Rewrite quote items
|
||||
|
|
@ -360,11 +359,11 @@ export default function RewriteQuoteModal({
|
|||
{!results ? (
|
||||
// ───────────────────────────────────────── Setup state
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="px-6 pb-3 space-y-3 shrink-0">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
<div className="px-6 py-3 shrink-0 border-b">
|
||||
<div className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Transformations
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-1.5">
|
||||
{TRANSFORMATIONS.map((t) => (
|
||||
<GlobalOptionToggle
|
||||
key={t.key}
|
||||
|
|
@ -378,8 +377,6 @@ export default function RewriteQuoteModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="shrink-0" />
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-3">
|
||||
<div
|
||||
className="sticky top-0 z-20 -mx-6 px-6 py-2.5 mb-3 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||
|
|
@ -389,7 +386,7 @@ export default function RewriteQuoteModal({
|
|||
Items ({includedIds.size} of {items.length} included)
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2" data-testid="rewrite-item-list">
|
||||
<ul className="space-y-1.5" data-testid="rewrite-item-list">
|
||||
{items.map((item, i) => {
|
||||
const included = includedIds.has(item.id);
|
||||
const opts = perItemOptions[item.id] ?? globalOptions;
|
||||
|
|
@ -397,7 +394,7 @@ export default function RewriteQuoteModal({
|
|||
<li
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"rounded-md border p-3 space-y-2",
|
||||
"rounded-md border bg-card p-2.5 space-y-1.5",
|
||||
!included && "opacity-60"
|
||||
)}
|
||||
data-testid={`rewrite-item-${item.id}`}
|
||||
|
|
@ -470,7 +467,7 @@ export default function RewriteQuoteModal({
|
|||
size="sm"
|
||||
onClick={callRewrite}
|
||||
disabled={loading || includedIds.size === 0 || tickedCount === 0}
|
||||
className="bg-amber-400 hover:bg-amber-500 text-amber-950 border-amber-500 shadow-sm font-semibold"
|
||||
className="font-semibold shadow-sm"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
|
|
@ -602,8 +599,8 @@ function GlobalOptionToggle({
|
|||
className={cn(
|
||||
"flex items-start gap-2.5 rounded-md border px-3 py-2 cursor-pointer transition-colors",
|
||||
checked
|
||||
? "bg-amber-50 border-amber-300 hover:bg-amber-100 dark:bg-amber-950/30 dark:border-amber-700/60 dark:hover:bg-amber-950/50"
|
||||
: "bg-background hover:bg-accent/30"
|
||||
? "bg-primary/8 border-primary/40 hover:bg-primary/12"
|
||||
: "bg-card hover:bg-accent/40 border-border"
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "./auth-provider";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Building2,
|
||||
Truck,
|
||||
FileText,
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "./auth-provider";
|
||||
import { useTheme } from "next-themes";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Building2,
|
||||
Truck,
|
||||
FileText,
|
||||
ShoppingCart,
|
||||
LogOut,
|
||||
Sun,
|
||||
|
|
@ -22,245 +23,442 @@ import {
|
|||
Wrench,
|
||||
ChevronDown,
|
||||
MapPinned,
|
||||
Settings as SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/orders", label: "Orders", icon: ShoppingCart },
|
||||
{ href: "/collection-pool", label: "Collection Pool", icon: Package },
|
||||
{ href: "/delivery-notes", label: "Delivery Notes", icon: ClipboardList },
|
||||
{ href: "/repairs", label: "Repairs", icon: Wrench },
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
/* ============================================================================
|
||||
* CATEGORY SYSTEM
|
||||
*
|
||||
* Sidebar nav is grouped into 4 categories, each with a fixed brand color
|
||||
* so users can scan by color. Colors are intentionally hard-coded — they
|
||||
* stay the same regardless of which theme is active, so the sidebar reads
|
||||
* consistently.
|
||||
*
|
||||
* dashboard → blue (brand)
|
||||
* management → blue (brand)
|
||||
* quotes → gold (brand)
|
||||
* operations → red (brand)
|
||||
* ========================================================================== */
|
||||
type CategoryId = "dashboard" | "management" | "quotes" | "operations";
|
||||
|
||||
const CATEGORY_COLORS: Record<CategoryId, string> = {
|
||||
dashboard: "#4C96D7",
|
||||
management: "#4C96D7",
|
||||
quotes: "#F2AF58",
|
||||
operations: "#E94E49",
|
||||
};
|
||||
|
||||
// Helper — produce 8-digit hex with alpha (browser-portable, no color-mix needed)
|
||||
function withAlpha(hex: string, alphaPercent: number): string {
|
||||
const a = Math.round((alphaPercent / 100) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `${hex}${a}`;
|
||||
}
|
||||
|
||||
type NavEntry = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
category: CategoryId;
|
||||
};
|
||||
|
||||
const dashboardItems: NavEntry[] = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, category: "dashboard" },
|
||||
];
|
||||
|
||||
const managementItems = [
|
||||
{ href: "/customers", label: "Customers", icon: Users },
|
||||
{ href: "/companies", label: "Companies", icon: Building2 },
|
||||
{ href: "/suppliers", label: "Suppliers", icon: Truck },
|
||||
{ href: "/supplier-map", label: "Supplier Map", icon: MapPinned },
|
||||
const managementItems: NavEntry[] = [
|
||||
{ href: "/customers", label: "Customers", icon: Users, category: "management" },
|
||||
{ href: "/companies", label: "Companies", icon: Building2, category: "management" },
|
||||
{ href: "/suppliers", label: "Suppliers", icon: Truck, category: "management" },
|
||||
{ href: "/supplier-map", label: "Supplier Map", icon: MapPinned, category: "management" },
|
||||
];
|
||||
|
||||
const quoteItems = [
|
||||
{ href: "/quotes", label: "All Quotes", icon: FileText },
|
||||
{ href: "/quotes/approvals", label: "Pending Approvals", icon: CheckCircle },
|
||||
{ href: "/quotes/archived", label: "Archived", icon: Archive },
|
||||
const quoteItems: NavEntry[] = [
|
||||
{ href: "/quotes", label: "All Quotes", icon: FileText, category: "quotes" },
|
||||
{ href: "/quotes/approvals", label: "Pending Approvals", icon: CheckCircle, category: "quotes" },
|
||||
{ href: "/quotes/archived", label: "Archived", icon: Archive, category: "quotes" },
|
||||
];
|
||||
|
||||
function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
|
||||
|
||||
const operationsItems: NavEntry[] = [
|
||||
{ href: "/orders", label: "Orders", icon: ShoppingCart, category: "operations" },
|
||||
{ href: "/collection-pool", label: "Collection Pool", icon: Package, category: "operations" },
|
||||
{ href: "/delivery-notes", label: "Delivery Notes", icon: ClipboardList, category: "operations" },
|
||||
{ href: "/repairs", label: "Repairs", icon: Wrench, category: "operations" },
|
||||
];
|
||||
|
||||
const CATEGORY_LABELS: Record<CategoryId, string> = {
|
||||
dashboard: "Dashboard",
|
||||
management: "Management",
|
||||
quotes: "Quotes",
|
||||
operations: "Operations",
|
||||
};
|
||||
|
||||
/* ============================================================================
|
||||
* NavItem — single nav link with category-colored icon
|
||||
* ========================================================================== */
|
||||
function NavItem({
|
||||
item,
|
||||
active,
|
||||
onClick,
|
||||
indent = false,
|
||||
}: {
|
||||
item: NavEntry;
|
||||
active: boolean;
|
||||
onClick?: () => void;
|
||||
indent?: boolean;
|
||||
}) {
|
||||
const color = CATEGORY_COLORS[item.category];
|
||||
// Inactive: subtle category tint. Active: white-on-primary from theme.
|
||||
const inactiveStyle = active
|
||||
? undefined
|
||||
: { color: withAlpha(color, 78) };
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onClick}
|
||||
style={inactiveStyle}
|
||||
className={`group flex items-center gap-3 rounded-xl ${
|
||||
indent ? "px-2.5 py-2 text-sm" : "px-3 py-2.5 text-sm"
|
||||
} font-medium transition-all duration-150 ${
|
||||
active
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "hover:opacity-80"
|
||||
}`}
|
||||
>
|
||||
<item.icon
|
||||
className={`${indent ? "h-4 w-4" : "h-[18px] w-[18px]"} shrink-0`}
|
||||
style={active ? { color: "var(--primary-foreground)" } : { color }}
|
||||
/>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* NavGroup — accordion wrapper tinted with category color
|
||||
* ========================================================================== */
|
||||
function NavGroup({
|
||||
category,
|
||||
open,
|
||||
onToggle,
|
||||
headerActive,
|
||||
headerIcon,
|
||||
items,
|
||||
pathname,
|
||||
onLinkClick,
|
||||
}: {
|
||||
category: CategoryId;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
headerActive: boolean;
|
||||
headerIcon: LucideIcon;
|
||||
items: NavEntry[];
|
||||
pathname: string;
|
||||
onLinkClick?: () => void;
|
||||
}) {
|
||||
const color = CATEGORY_COLORS[category];
|
||||
const HeaderIcon = headerIcon;
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border p-1.5"
|
||||
style={{
|
||||
background: withAlpha(color, open ? 10 : 6),
|
||||
borderColor: withAlpha(color, open ? 28 : 18),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors ${
|
||||
headerActive ? "" : "hover:opacity-80"
|
||||
}`}
|
||||
style={{
|
||||
color: headerActive ? color : withAlpha(color, 78),
|
||||
}}
|
||||
>
|
||||
<HeaderIcon
|
||||
className="h-3.5 w-3.5"
|
||||
style={{ color: headerActive ? color : color }}
|
||||
/>
|
||||
{CATEGORY_LABELS[category]}
|
||||
<ChevronDown
|
||||
className={`ml-auto h-3.5 w-3.5 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
style={{ color: withAlpha(color, 60) }}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/quotes"
|
||||
? pathname === "/quotes"
|
||||
: pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<NavItem
|
||||
key={item.href}
|
||||
item={item}
|
||||
active={isActive}
|
||||
onClick={onLinkClick}
|
||||
indent
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* NavContent — the actual sidebar body
|
||||
* ========================================================================== */
|
||||
function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const { user, logout } = useAuth();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isManagementRoute = managementItems.some((item) => pathname === item.href || pathname.startsWith(item.href + "/"));
|
||||
const isQuotesRoute = pathname === "/quotes" || pathname.startsWith("/quotes/");
|
||||
const [managementOpen, setManagementOpen] = useState(isManagementRoute);
|
||||
const [quotesOpen, setQuotesOpen] = useState(isQuotesRoute);
|
||||
|
||||
const isManagementActive = managementItems.some(
|
||||
(item) => pathname === item.href || pathname.startsWith(item.href + "/")
|
||||
);
|
||||
const isQuotesActive =
|
||||
pathname === "/quotes" || pathname.startsWith("/quotes/");
|
||||
const isOperationsActive = operationsItems.some(
|
||||
(item) => pathname === item.href || pathname.startsWith(item.href + "/")
|
||||
);
|
||||
|
||||
const [managementOpen, setManagementOpen] = useState(isManagementActive);
|
||||
const [quotesOpen, setQuotesOpen] = useState(isQuotesActive);
|
||||
|
||||
// Auto-open accordion when route inside it becomes active
|
||||
useEffect(() => {
|
||||
if (isManagementRoute) setManagementOpen(true);
|
||||
if (isQuotesRoute) setQuotesOpen(true);
|
||||
}, [isManagementRoute, isQuotesRoute]);
|
||||
|
||||
if (isManagementActive) setManagementOpen(true);
|
||||
if (isQuotesActive) setQuotesOpen(true);
|
||||
}, [isManagementActive, isQuotesActive]);
|
||||
|
||||
const isDashboardActive =
|
||||
pathname === "/dashboard" || pathname.startsWith("/dashboard/");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Brand block */}
|
||||
<div className="px-5 pt-6 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid size-10 place-items-center rounded-xl bg-primary text-primary-foreground shadow-sm">
|
||||
<Package className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight text-foreground">WFDASH</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Westcott Fasteners</p>
|
||||
<h1 className="text-lg font-semibold tracking-tight text-foreground font-heading-theme">
|
||||
WFDASH
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Westcott Fasteners
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Brand color stripe */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-1">
|
||||
<span className="h-1.5 rounded-full bg-primary" />
|
||||
<span className="h-1.5 rounded-full bg-destructive" />
|
||||
<span className="h-1.5 rounded-full bg-accent" />
|
||||
<span className="h-1.5 rounded-full" style={{ background: "#4C96D7" }} />
|
||||
<span className="h-1.5 rounded-full" style={{ background: "#F2AF58" }} />
|
||||
<span className="h-1.5 rounded-full" style={{ background: "#E94E49" }} />
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="mx-3" />
|
||||
|
||||
{/* Main nav */}
|
||||
<ScrollArea className="flex-1 px-3 py-3">
|
||||
<nav className="space-y-1">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
<nav className="space-y-1.5">
|
||||
{/* Dashboard — flat top item, no accordion */}
|
||||
<NavItem
|
||||
item={dashboardItems[0]}
|
||||
active={isDashboardActive}
|
||||
onClick={onLinkClick}
|
||||
className={`flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-150 ${
|
||||
pathname === "/dashboard" || pathname.startsWith("/dashboard/")
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="h-[18px] w-[18px] shrink-0" />
|
||||
Dashboard
|
||||
</Link>
|
||||
/>
|
||||
|
||||
<div className="rounded-xl border border-sidebar-border/70 bg-sidebar-accent/45 p-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManagementOpen((open) => !open)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-background/70 hover:text-foreground"
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Management
|
||||
<ChevronDown className={`ml-auto h-3.5 w-3.5 transition-transform ${managementOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{managementOpen && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{managementItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onLinkClick}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-background/70 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Management — accordion */}
|
||||
<NavGroup
|
||||
category="management"
|
||||
open={managementOpen}
|
||||
onToggle={() => setManagementOpen((o) => !o)}
|
||||
headerActive={isManagementActive}
|
||||
headerIcon={Users}
|
||||
items={managementItems}
|
||||
pathname={pathname}
|
||||
onLinkClick={onLinkClick}
|
||||
/>
|
||||
|
||||
{/* Quotes — accordion */}
|
||||
<NavGroup
|
||||
category="quotes"
|
||||
open={quotesOpen}
|
||||
onToggle={() => setQuotesOpen((o) => !o)}
|
||||
headerActive={isQuotesActive}
|
||||
headerIcon={FileText}
|
||||
items={quoteItems}
|
||||
pathname={pathname}
|
||||
onLinkClick={onLinkClick}
|
||||
/>
|
||||
|
||||
{/* Operations — flat items, but with a tiny colored gutter on the left */}
|
||||
<OperationsList
|
||||
active={isOperationsActive}
|
||||
pathname={pathname}
|
||||
onLinkClick={onLinkClick}
|
||||
/>
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
|
||||
<Separator className="mx-3" />
|
||||
|
||||
{/* Bottom block: settings (icon-only) + theme + avatar + logout */}
|
||||
<div className="p-3 space-y-2">
|
||||
{/* Settings — small icon-only button bottom-left */}
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={onLinkClick}
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
className="grid size-9 place-items-center rounded-xl text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<SettingsIcon className="h-[18px] w-[18px]" />
|
||||
</Link>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-3 text-muted-foreground hover:text-foreground h-9 px-2.5 rounded-xl"
|
||||
onClick={() => setTheme(theme === "dark" ? "warm" : "dark")}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<>
|
||||
<Sun className="h-[18px] w-[18px]" />
|
||||
<span className="text-sm">Light Mode</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Moon className="h-[18px] w-[18px]" />
|
||||
<span className="text-sm">Dark Mode</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* User */}
|
||||
<div className="flex items-center gap-3 px-2.5 py-2">
|
||||
<Avatar className="h-9 w-9 shrink-0 ring-2 ring-border/50">
|
||||
<AvatarFallback className="text-xs font-medium bg-muted">
|
||||
{user?.name?.charAt(0)?.toUpperCase() ||
|
||||
user?.email?.charAt(0)?.toUpperCase() ||
|
||||
"U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{user?.name || "User"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-sidebar-border/70 bg-sidebar-accent/45 p-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuotesOpen((open) => !open)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors ${
|
||||
isQuotesRoute
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:bg-background/70 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
Quotes
|
||||
<ChevronDown className={`ml-auto h-3.5 w-3.5 transition-transform ${quotesOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{quotesOpen && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{quoteItems.map((item) => {
|
||||
const isActive = item.href === "/quotes"
|
||||
? pathname === "/quotes"
|
||||
: pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onLinkClick}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-background/70 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<div key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onLinkClick}
|
||||
className={`flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-150 ${
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
<item.icon className="h-[18px] w-[18px] shrink-0" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
<Separator className="mx-3" />
|
||||
<div className="p-3 space-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-3 text-muted-foreground hover:text-foreground h-9 px-2.5 rounded-xl"
|
||||
onClick={() => setTheme(theme === "dark" ? "warm" : "dark")}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<>
|
||||
<Sun className="h-[18px] w-[18px]" />
|
||||
<span className="text-sm">Light Mode</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Moon className="h-[18px] w-[18px]" />
|
||||
<span className="text-sm">Dark Mode</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-3 px-2.5 py-2">
|
||||
<Avatar className="h-9 w-9 shrink-0 ring-2 ring-border/50">
|
||||
<AvatarFallback className="text-xs font-medium bg-muted">
|
||||
{user?.name?.charAt(0)?.toUpperCase() || user?.email?.charAt(0)?.toUpperCase() || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{user?.name || "User"}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-3 text-destructive hover:text-destructive hover:bg-destructive/8 h-9 px-2.5 rounded-xl"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="h-[18px] w-[18px]" />
|
||||
<span className="text-sm">Logout</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="hidden lg:flex lg:w-60 lg:flex-col bg-sidebar border-r border-sidebar-border">
|
||||
<NavContent />
|
||||
</aside>
|
||||
|
||||
<div className="lg:hidden fixed top-0 left-0 right-0 z-40 flex items-center gap-3 border-b bg-background/95 backdrop-blur-sm px-4 h-14">
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger
|
||||
render={<Button variant="ghost" size="icon" className="h-9 w-9" />}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-60 p-0">
|
||||
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||
<NavContent onLinkClick={() => setOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<h1 className="text-lg font-semibold">WFDASH</h1>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
{/* Logout */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-3 text-destructive hover:text-destructive hover:bg-destructive/8 h-9 px-2.5 rounded-xl"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="h-[18px] w-[18px]" />
|
||||
<span className="text-sm">Logout</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* OperationsList — flat items with a 4px red left gutter
|
||||
*
|
||||
* Operations is the only flat category. To make it visually consistent with
|
||||
* the accordion groups above, we wrap it in a thin red gutter that hints
|
||||
* at the category color without painting a full background.
|
||||
* ========================================================================== */
|
||||
function OperationsList({
|
||||
active,
|
||||
pathname,
|
||||
onLinkClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
pathname: string;
|
||||
onLinkClick?: () => void;
|
||||
}): ReactNode {
|
||||
const color = CATEGORY_COLORS.operations;
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border p-1.5 space-y-0.5"
|
||||
style={{
|
||||
background: withAlpha(color, active ? 10 : 6),
|
||||
borderColor: withAlpha(color, active ? 28 : 18),
|
||||
}}
|
||||
>
|
||||
{/* Tiny header label so the category is identifiable */}
|
||||
<div
|
||||
className="px-2 py-1 text-xs font-semibold uppercase tracking-wide flex items-center gap-2"
|
||||
style={{ color: withAlpha(color, 78) }}
|
||||
>
|
||||
<ShoppingCart className="h-3.5 w-3.5" style={{ color }} />
|
||||
Operations
|
||||
</div>
|
||||
{operationsItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<NavItem
|
||||
key={item.href}
|
||||
item={item}
|
||||
active={isActive}
|
||||
onClick={onLinkClick}
|
||||
indent
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Sidebar — desktop + mobile (Sheet) wrappers
|
||||
* ========================================================================== */
|
||||
export function Sidebar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="hidden lg:flex lg:w-60 lg:flex-col bg-sidebar border-r border-sidebar-border">
|
||||
<NavContent />
|
||||
</aside>
|
||||
|
||||
<div className="lg:hidden fixed top-0 left-0 right-0 z-40 flex items-center gap-3 border-b bg-background/95 backdrop-blur-sm px-4 h-14">
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger
|
||||
render={<Button variant="ghost" size="icon" className="h-9 w-9" />}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-60 p-0">
|
||||
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||
<NavContent onLinkClick={() => setOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<h1 className="text-lg font-semibold font-heading-theme">WFDASH</h1>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Maximize2, Mail, MapPin, Minimize2, Move, Phone, RefreshCw, Search, Tag } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getAllSuppliers } from "@/lib/pb-helpers";
|
||||
import { getMappableSuppliers } from "@/lib/pb-helpers";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import type { Supplier } from "@/lib/types";
|
||||
|
||||
|
|
@ -30,7 +30,15 @@ const DEFAULT_CENTER: [number, number] = [-26.2041, 28.0473];
|
|||
const CLUSTER_DISTANCE_KM = 8;
|
||||
const CLUSTER_ZOOM_BREAKPOINT = 12;
|
||||
const LABEL_ZOOM_BREAKPOINT = 12;
|
||||
/**
|
||||
* Max number of permanent supplier-name labels visible on the map at
|
||||
* once. Picked by distance to viewport centre — keeps the DOM light
|
||||
* when zoomed in over a dense cluster of pins.
|
||||
*/
|
||||
const LABEL_LIMIT = 24;
|
||||
const CLUSTER_COLORS = ["#1B76BB", "#E64B4B", "#F4B842", "#169B62", "#7C3AED", "#EA580C", "#0F766E"];
|
||||
/** Stable empty Set so consumers can `has()` without re-creating it. */
|
||||
const EMPTY_LABELED_IDS = new Set<string>();
|
||||
const LIGHT_TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||
const DARK_TILE_URL = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
|
||||
const LIGHT_TILE_ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
|
||||
|
|
@ -125,7 +133,15 @@ function buildClusters(points: SupplierPoint[], distanceKmLimit = CLUSTER_DISTAN
|
|||
return clusters;
|
||||
}
|
||||
|
||||
function MapWatcher({ isFullscreen, onZoomChange }: { isFullscreen: boolean; onZoomChange: (zoom: number) => void }) {
|
||||
function MapWatcher({
|
||||
isFullscreen,
|
||||
onZoomChange,
|
||||
onViewportChange,
|
||||
}: {
|
||||
isFullscreen: boolean;
|
||||
onZoomChange: (zoom: number) => void;
|
||||
onViewportChange: (viewport: { center: [number, number]; zoom: number }) => void;
|
||||
}) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -133,7 +149,18 @@ function MapWatcher({ isFullscreen, onZoomChange }: { isFullscreen: boolean; onZ
|
|||
}, [isFullscreen, map]);
|
||||
|
||||
useMapEvents({
|
||||
zoomend: (event) => onZoomChange(event.target.getZoom()),
|
||||
zoomend: (event) => {
|
||||
const target = event.target;
|
||||
const zoom = target.getZoom();
|
||||
const center = target.getCenter();
|
||||
onZoomChange(zoom);
|
||||
onViewportChange({ center: [center.lat, center.lng], zoom });
|
||||
},
|
||||
moveend: (event) => {
|
||||
const target = event.target;
|
||||
const center = target.getCenter();
|
||||
onViewportChange({ center: [center.lat, center.lng], zoom: target.getZoom() });
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
|
|
@ -191,6 +218,7 @@ export function SupplierMap() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [zoom, setZoom] = useState(10);
|
||||
const [viewport, setViewport] = useState<{ center: [number, number]; zoom: number } | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const isDarkMap = resolvedTheme === "dark";
|
||||
|
|
@ -198,7 +226,7 @@ export function SupplierMap() {
|
|||
const fetchSuppliers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setSuppliers(await getAllSuppliers());
|
||||
setSuppliers(await getMappableSuppliers());
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to load suppliers");
|
||||
} finally {
|
||||
|
|
@ -258,6 +286,11 @@ export function SupplierMap() {
|
|||
}, [search, suppliers]);
|
||||
|
||||
const clusterDistanceKm = clusterDistanceForZoom(zoom);
|
||||
// `clusterDistanceKm` only changes when zoom crosses one of
|
||||
// {7, 9, 11} (see clusterDistanceForZoom). Dep'ing on the bucket
|
||||
// value — not on zoom — keeps `buildClusters` from re-running on
|
||||
// every wheel tick. If you change the cluster distance function,
|
||||
// keep this bucketed.
|
||||
const clusters = useMemo(() => buildClusters(mapped, clusterDistanceKm), [clusterDistanceKm, mapped]);
|
||||
const showIndividualSuppliers = zoom >= CLUSTER_ZOOM_BREAKPOINT;
|
||||
const showLabels = zoom >= LABEL_ZOOM_BREAKPOINT;
|
||||
|
|
@ -268,8 +301,34 @@ export function SupplierMap() {
|
|||
] as [number, number])
|
||||
: DEFAULT_CENTER;
|
||||
|
||||
const clusterBySupplierId = new Map<string, SupplierCluster>();
|
||||
clusters.forEach((cluster) => cluster.suppliers.forEach((supplier) => clusterBySupplierId.set(supplier.id, cluster)));
|
||||
const clusterBySupplierId = useMemo(() => {
|
||||
const m = new Map<string, SupplierCluster>();
|
||||
for (const cluster of clusters) {
|
||||
for (const supplier of cluster.suppliers) {
|
||||
m.set(supplier.id, cluster);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [clusters]);
|
||||
|
||||
/**
|
||||
* IDs of the suppliers that should render a permanent name label
|
||||
* at the current zoom. Top LABEL_LIMIT by squared distance to the
|
||||
* viewport centre — cheap, no sqrt, and "centre of viewport" is a
|
||||
* good enough proxy for "what the user is actually looking at".
|
||||
*/
|
||||
const labeledIds = useMemo(() => {
|
||||
if (!showLabels || !viewport) return EMPTY_LABELED_IDS;
|
||||
const [cLat, cLng] = viewport.center;
|
||||
const ranked = mapped
|
||||
.map((supplier) => ({
|
||||
id: supplier.id,
|
||||
d: (supplier.lat - cLat) ** 2 + (supplier.lng - cLng) ** 2,
|
||||
}))
|
||||
.sort((a, b) => a.d - b.d)
|
||||
.slice(0, LABEL_LIMIT);
|
||||
return new Set(ranked.map((entry) => entry.id));
|
||||
}, [mapped, showLabels, viewport]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
|
|
@ -328,8 +387,8 @@ export function SupplierMap() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<Card className={`overflow-hidden border-primary/10 ${isFullscreen ? "fixed inset-0 z-[45] rounded-none border-0" : "relative z-0"}`}>
|
||||
<div className="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<Card className={`overflow-hidden border-primary/10 py-0 ${isFullscreen ? "fixed inset-0 z-[45] rounded-none border-0" : "relative z-0"}`}>
|
||||
<div className={`${isFullscreen ? "h-dvh min-h-0" : "h-[68vh] min-h-[520px]"} relative`}>
|
||||
<div className="absolute right-3 top-3 z-[30] flex flex-col gap-2 sm:flex-row">
|
||||
{editMode && (
|
||||
|
|
@ -349,7 +408,11 @@ export function SupplierMap() {
|
|||
</Button>
|
||||
</div>
|
||||
<MapContainer center={mapCenter} zoom={mapped.length ? 10 : 6} scrollWheelZoom className="h-full w-full">
|
||||
<MapWatcher isFullscreen={isFullscreen} onZoomChange={setZoom} />
|
||||
<MapWatcher
|
||||
isFullscreen={isFullscreen}
|
||||
onZoomChange={setZoom}
|
||||
onViewportChange={setViewport}
|
||||
/>
|
||||
<TileLayer
|
||||
key={isDarkMap ? "dark" : "light"}
|
||||
attribution={isDarkMap ? DARK_TILE_ATTRIBUTION : LIGHT_TILE_ATTRIBUTION}
|
||||
|
|
@ -386,7 +449,7 @@ export function SupplierMap() {
|
|||
},
|
||||
}}
|
||||
>
|
||||
{showLabels && (
|
||||
{showLabels && labeledIds.has(supplier.id) && (
|
||||
<Tooltip direction="top" offset={[0, -12]} permanent className="supplier-map-label">
|
||||
{supplier.suppliername || "Unnamed supplier"}
|
||||
</Tooltip>
|
||||
|
|
|
|||
95
src/components/theme-bootstrap.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* Inline script that runs in <head> before hydration.
|
||||
*
|
||||
* Reads the persisted theme id + mode from localStorage and applies them
|
||||
* to <html> so the user never sees a flash of the default theme.
|
||||
*
|
||||
* MUST stay dependency-free — it's bundled as a string and inlined.
|
||||
*/
|
||||
export function ThemeBootstrapScript() {
|
||||
const code = `
|
||||
(function() {
|
||||
try {
|
||||
var themeId = localStorage.getItem('wfdash-theme') || 'crisp';
|
||||
var mode = localStorage.getItem('wfdash-mode') || 'warm';
|
||||
var root = document.documentElement;
|
||||
root.setAttribute('data-theme', themeId);
|
||||
root.classList.remove('warm', 'dark');
|
||||
root.classList.add(mode);
|
||||
var radiusMap = {
|
||||
'crisp': '0.625rem',
|
||||
'midnight': '0.75rem',
|
||||
'editorial': '0.375rem',
|
||||
'bento': '1.125rem',
|
||||
'field-ops': '0.5rem',
|
||||
'terminal': '0',
|
||||
'blueprint': '0.125rem',
|
||||
'confetti': '1.25rem'
|
||||
};
|
||||
if (radiusMap[themeId]) root.style.setProperty('--radius', radiusMap[themeId]);
|
||||
|
||||
// If the active theme is a custom theme, apply its inline token overrides
|
||||
// so the page renders correctly before React hydrates.
|
||||
var activeCustomId = localStorage.getItem('wfdash-active-custom-id');
|
||||
if (activeCustomId) {
|
||||
try {
|
||||
var raw = localStorage.getItem('wfdash-custom-themes');
|
||||
if (raw) {
|
||||
var list = JSON.parse(raw);
|
||||
var found = null;
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (list[i].id === activeCustomId) { found = list[i]; break; }
|
||||
}
|
||||
if (found && found.colors) {
|
||||
var c = found.colors;
|
||||
// Direct mappings
|
||||
root.style.setProperty('--background', c.background);
|
||||
root.style.setProperty('--foreground', c.foreground);
|
||||
root.style.setProperty('--primary', c.primary);
|
||||
root.style.setProperty('--accent', c.accent);
|
||||
root.style.setProperty('--destructive', c.destructive);
|
||||
root.style.setProperty('--sidebar', c.sidebar);
|
||||
root.style.setProperty('--border', c.border);
|
||||
// Contrast foregrounds
|
||||
function lum(hex){
|
||||
var r = parseInt(hex.slice(1,3),16)/255;
|
||||
var g = parseInt(hex.slice(3,5),16)/255;
|
||||
var b = parseInt(hex.slice(5,7),16)/255;
|
||||
var f = function(v){return v<=0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055,2.4);};
|
||||
return 0.2126*f(r)+0.7152*f(g)+0.0722*f(b);
|
||||
}
|
||||
function fgFor(bg){ return lum(bg) > 0.55 ? '#0F172A' : '#FFFFFF'; }
|
||||
root.style.setProperty('--primary-foreground', fgFor(c.primary));
|
||||
root.style.setProperty('--accent-foreground', fgFor(c.accent));
|
||||
root.style.setProperty('--destructive-foreground', fgFor(c.destructive));
|
||||
root.style.setProperty('--sidebar-foreground', fgFor(c.sidebar));
|
||||
// Derived
|
||||
function hexA(hex,a){return hex+(Math.round(a*2.55).toString(16).padStart(2,'0'));}
|
||||
function mix(a,b,p){var ar=parseInt(a.slice(1,3),16),ag=parseInt(a.slice(3,5),16),ab=parseInt(a.slice(5,7),16),br=parseInt(b.slice(1,3),16),bg=parseInt(b.slice(3,5),16),bb=parseInt(b.slice(5,7),16);var k=p/100;var r=Math.round(ar*k+br*(1-k)),g=Math.round(ag*k+bg*(1-k)),bb2=Math.round(ab*k+bb*(1-k));var h=function(n){return n.toString(16).padStart(2,'0');};return '#'+h(r)+h(g)+h(bb2);}
|
||||
root.style.setProperty('--card', mix('#FFFFFF', c.background, 2));
|
||||
root.style.setProperty('--popover', mix('#FFFFFF', c.background, 2));
|
||||
root.style.setProperty('--muted', mix('#000000', c.background, 4));
|
||||
root.style.setProperty('--secondary', mix('#000000', c.background, 3));
|
||||
root.style.setProperty('--card-foreground', c.foreground);
|
||||
root.style.setProperty('--popover-foreground', c.foreground);
|
||||
root.style.setProperty('--secondary-foreground', c.foreground);
|
||||
root.style.setProperty('--muted-foreground', mix('#94A3B8', c.foreground, 60));
|
||||
root.style.setProperty('--input', c.border);
|
||||
root.style.setProperty('--ring', c.primary);
|
||||
root.style.setProperty('--sidebar-primary', c.primary);
|
||||
root.style.setProperty('--sidebar-primary-foreground', fgFor(c.primary));
|
||||
root.style.setProperty('--sidebar-accent', mix('#FFFFFF', c.sidebar, 8));
|
||||
root.style.setProperty('--sidebar-accent-foreground', fgFor(c.sidebar));
|
||||
root.style.setProperty('--sidebar-border', mix('#FFFFFF', c.sidebar, 12));
|
||||
root.style.setProperty('--sidebar-ring', c.primary);
|
||||
}
|
||||
}
|
||||
} catch(e2) { /* ignore */ }
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage may be blocked — fall back to defaults silently
|
||||
}
|
||||
})();
|
||||
`;
|
||||
return <script dangerouslySetInnerHTML={{ __html: code }} />;
|
||||
}
|
||||
395
src/components/theme-picker.tsx
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { THEMES } from "@/lib/themes";
|
||||
import { useThemeId } from "@/lib/use-theme-id";
|
||||
import {
|
||||
applyCustomThemeToDocument,
|
||||
CUSTOM_THEME_ID,
|
||||
loadCustomThemes,
|
||||
saveActiveCustomThemeId,
|
||||
type CustomTheme,
|
||||
} from "@/lib/custom-themes";
|
||||
import { CustomThemeModal } from "@/components/custom-theme-modal";
|
||||
import { Check, Moon, Sun, Sparkles, Pencil } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* ThemePicker — settings UI for picking visual theme + warm/dark mode.
|
||||
*
|
||||
* - Mode toggle (light/dark) — independent of theme
|
||||
* - 8 preset themes
|
||||
* - Custom themes (user-created) listed below
|
||||
* - "Customize" button opens CustomThemeModal
|
||||
*/
|
||||
export function ThemePicker() {
|
||||
const { theme: mode, setTheme: setMode } = useTheme();
|
||||
const { themeId, setThemeId } = useThemeId();
|
||||
const [customThemes, setCustomThemes] = useState<CustomTheme[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingCustomId, setEditingCustomId] = useState<string | null>(null);
|
||||
|
||||
const refreshCustomThemes = () => {
|
||||
setCustomThemes(loadCustomThemes());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
refreshCustomThemes();
|
||||
}, []);
|
||||
|
||||
const standardThemes = THEMES.filter((t) => t.category === "standard");
|
||||
const standoutThemes = THEMES.filter((t) => t.category === "standout");
|
||||
|
||||
const handleSelectCustom = (id: string) => {
|
||||
const theme = customThemes.find((t) => t.id === id);
|
||||
if (!theme) return;
|
||||
applyCustomThemeToDocument(theme);
|
||||
saveActiveCustomThemeId(id);
|
||||
setThemeId(CUSTOM_THEME_ID);
|
||||
refreshCustomThemes();
|
||||
};
|
||||
|
||||
const handleSelectPreset = (id: string) => {
|
||||
// Switch away from any custom theme
|
||||
saveActiveCustomThemeId(null);
|
||||
// Remove inline custom-theme overrides
|
||||
applyCustomThemeToDocument(null);
|
||||
setThemeId(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Mode toggle */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Mode
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 max-w-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("warm")}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors",
|
||||
mode === "warm"
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-card hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("dark")}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors",
|
||||
mode === "dark"
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-card hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Standard themes */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Standard themes
|
||||
<span className="text-[10px] font-normal text-muted-foreground/70 normal-case tracking-normal">
|
||||
· ship cleanly with shadcn/ui
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{standardThemes.map((t) => (
|
||||
<ThemeCard
|
||||
key={t.id}
|
||||
theme={t}
|
||||
active={themeId === t.id}
|
||||
onSelect={() => handleSelectPreset(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Standout themes */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Standout themes
|
||||
<span className="text-[10px] font-normal text-muted-foreground/70 normal-case tracking-normal">
|
||||
· for users who want to stand out
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{standoutThemes.map((t) => (
|
||||
<ThemeCard
|
||||
key={t.id}
|
||||
theme={t}
|
||||
active={themeId === t.id}
|
||||
onSelect={() => handleSelectPreset(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom themes */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<Pencil className="h-3 w-3" />
|
||||
Custom themes
|
||||
<span className="text-[10px] font-normal text-muted-foreground/70 normal-case tracking-normal">
|
||||
· your saved themes
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingCustomId(null);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/8 px-2.5 py-1.5 text-xs font-semibold text-primary hover:bg-primary/15 transition-colors"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Customize
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{customThemes.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
No custom themes yet. Click <b className="text-foreground">Customize</b> to create
|
||||
one from a preset or from scratch.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{customThemes.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectCustom(t.id)}
|
||||
className={cn(
|
||||
"group relative flex flex-col gap-2 rounded-xl border-2 bg-card p-3 text-left transition-all hover:shadow-md",
|
||||
themeId === CUSTOM_THEME_ID
|
||||
? "border-primary shadow-sm"
|
||||
: "border-border hover:border-primary/40"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-lg border"
|
||||
style={{
|
||||
background: t.colors.background,
|
||||
borderColor: "rgba(0,0,0,.08)",
|
||||
aspectRatio: "16 / 9",
|
||||
}}
|
||||
>
|
||||
{/* Mini preview */}
|
||||
<div className="flex h-full">
|
||||
<div
|
||||
className="w-1/4 h-full p-1.5"
|
||||
style={{ background: t.colors.sidebar }}
|
||||
>
|
||||
<div
|
||||
className="h-3 w-3 rounded grid place-items-center text-[6px] font-bold"
|
||||
style={{
|
||||
background: t.colors.primary,
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
W
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 p-2 space-y-1.5">
|
||||
<div
|
||||
className="h-3 rounded"
|
||||
style={{ background: t.colors.primary, opacity: 0.85, width: "55%" }}
|
||||
/>
|
||||
<div
|
||||
className="h-2 rounded"
|
||||
style={{
|
||||
background: t.colors.foreground,
|
||||
opacity: 0.18,
|
||||
width: "90%",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-2 rounded"
|
||||
style={{
|
||||
background: t.colors.foreground,
|
||||
opacity: 0.10,
|
||||
width: "70%",
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-1 mt-1.5">
|
||||
<div
|
||||
className="h-3 px-1.5 rounded text-[7px] font-bold flex items-center"
|
||||
style={{
|
||||
background: t.colors.primary,
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
Go
|
||||
</div>
|
||||
<div
|
||||
className="h-3 px-1.5 rounded text-[7px] font-bold flex items-center"
|
||||
style={{
|
||||
background: t.colors.accent,
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
Acc
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{themeId === CUSTOM_THEME_ID && (
|
||||
<div className="absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-full bg-primary text-primary-foreground shadow">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-semibold flex items-center gap-1.5">
|
||||
{t.name}
|
||||
<span className="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[8px] font-bold uppercase tracking-widest text-primary">
|
||||
Custom
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingCustomId(t.id);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.stopPropagation();
|
||||
setEditingCustomId(t.id);
|
||||
setModalOpen(true);
|
||||
}
|
||||
}}
|
||||
className="absolute top-2 right-2 grid h-7 w-7 place-items-center rounded-md bg-background/90 border border-border opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer hover:bg-accent"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CustomThemeModal
|
||||
open={modalOpen}
|
||||
onOpenChange={(o) => {
|
||||
setModalOpen(o);
|
||||
if (!o) {
|
||||
setEditingCustomId(null);
|
||||
refreshCustomThemes();
|
||||
}
|
||||
}}
|
||||
editingThemeId={editingCustomId}
|
||||
onChange={refreshCustomThemes}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeCard({
|
||||
theme,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
theme: (typeof THEMES)[number];
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group relative flex flex-col gap-2 rounded-xl border-2 bg-card p-3 text-left transition-all hover:shadow-md",
|
||||
active ? "border-primary shadow-sm" : "border-border hover:border-primary/40"
|
||||
)}
|
||||
>
|
||||
{/* Mini preview */}
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-lg border"
|
||||
style={{
|
||||
background: theme.swatches.surface,
|
||||
borderColor: "rgba(0,0,0,.08)",
|
||||
aspectRatio: "16 / 9",
|
||||
}}
|
||||
>
|
||||
{/* Fake window chrome */}
|
||||
<div
|
||||
className="flex items-center gap-1 border-b px-2 py-1.5"
|
||||
style={{ borderColor: "rgba(0,0,0,.08)" }}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#FF5F57" }} />
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#FEBC2E" }} />
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#28C840" }} />
|
||||
</div>
|
||||
{/* Fake content */}
|
||||
<div className="grid grid-cols-3 gap-1.5 p-2">
|
||||
<div
|
||||
className="col-span-1 h-12 rounded"
|
||||
style={{ background: theme.swatches.primary, opacity: 0.92 }}
|
||||
/>
|
||||
<div
|
||||
className="col-span-1 h-12 rounded"
|
||||
style={{ background: theme.swatches.gold, opacity: 0.92 }}
|
||||
/>
|
||||
<div
|
||||
className="col-span-1 h-12 rounded"
|
||||
style={{ background: theme.swatches.red, opacity: 0.92 }}
|
||||
/>
|
||||
<div
|
||||
className="col-span-2 h-6 rounded"
|
||||
style={{
|
||||
background: theme.swatches.text,
|
||||
opacity: 0.12,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="col-span-1 h-6 rounded"
|
||||
style={{ background: theme.swatches.primary, opacity: 0.7 }}
|
||||
/>
|
||||
<div
|
||||
className="col-span-3 h-2 rounded"
|
||||
style={{ background: theme.swatches.text, opacity: 0.18 }}
|
||||
/>
|
||||
<div
|
||||
className="col-span-2 h-2 rounded"
|
||||
style={{ background: theme.swatches.text, opacity: 0.10 }}
|
||||
/>
|
||||
</div>
|
||||
{active && (
|
||||
<div className="absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-full bg-primary text-primary-foreground shadow">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Label + tagline */}
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-semibold">{theme.label}</div>
|
||||
{theme.category === "standout" && (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-accent px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-widest text-accent-foreground">
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
Creative
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground leading-snug">{theme.tagline}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
themes={["warm", "dark"]}
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
storageKey="wfdash-mode"
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
|
|
|
|||
34
src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"use client"
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer group/checkbox relative shrink-0 grid place-items-center size-4 rounded-sm border border-input bg-background shadow-xs transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 disabled:cursor-not-allowed disabled:opacity-50 data-checked:bg-primary data-checked:text-primary-foreground data-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-items-center text-current"
|
||||
>
|
||||
<Check
|
||||
className="size-3 stroke-[3]"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
416
src/lib/custom-themes.ts
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
/**
|
||||
* Custom theme system — user-defined color themes stored in localStorage.
|
||||
*
|
||||
* The user picks 7 colors (primary, accent, destructive, background, sidebar,
|
||||
* foreground, border). Everything else (~25 derived tokens) is computed
|
||||
* automatically using simple luminance + tint math.
|
||||
*
|
||||
* When a custom theme is active, all CSS custom properties are written as
|
||||
* inline style on <html>, which overrides the preset theme selectors.
|
||||
* `data-theme="custom"` is set so other code can branch on it.
|
||||
*/
|
||||
|
||||
export type CustomThemeColors = {
|
||||
primary: string;
|
||||
accent: string;
|
||||
destructive: string;
|
||||
background: string;
|
||||
sidebar: string;
|
||||
foreground: string;
|
||||
border: string;
|
||||
};
|
||||
|
||||
export type CustomTheme = {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: CustomThemeColors;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/* ============================================================================
|
||||
* Defaults — start from the Crisp theme's tokens
|
||||
* ========================================================================== */
|
||||
export const DEFAULT_CUSTOM_COLORS: CustomThemeColors = {
|
||||
primary: "#4C96D7",
|
||||
accent: "#F2AF58",
|
||||
destructive: "#E94E49",
|
||||
background: "#F8FAFC",
|
||||
sidebar: "#0F2E4C",
|
||||
foreground: "#0F172A",
|
||||
border: "#E2E8F0",
|
||||
};
|
||||
|
||||
export const CUSTOM_THEME_ID = "custom";
|
||||
export const STORAGE_KEY_CUSTOM_THEMES = "wfdash-custom-themes";
|
||||
export const STORAGE_KEY_ACTIVE_CUSTOM_ID = "wfdash-active-custom-id";
|
||||
|
||||
/* ============================================================================
|
||||
* Storage helpers
|
||||
* ========================================================================== */
|
||||
export function loadCustomThemes(): CustomTheme[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_CUSTOM_THEMES);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed as CustomTheme[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCustomThemes(themes: CustomTheme[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_CUSTOM_THEMES, JSON.stringify(themes));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadActiveCustomThemeId(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY_ACTIVE_CUSTOM_ID);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveActiveCustomThemeId(id: string | null): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (id) localStorage.setItem(STORAGE_KEY_ACTIVE_CUSTOM_ID, id);
|
||||
else localStorage.removeItem(STORAGE_KEY_ACTIVE_CUSTOM_ID);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Color math — WCAG luminance + simple tint/shade helpers
|
||||
* ========================================================================== */
|
||||
export function hexToRgb(hex: string): [number, number, number] | null {
|
||||
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim());
|
||||
if (!m) return null;
|
||||
return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)];
|
||||
}
|
||||
|
||||
function relativeLuminance(r: number, g: number, b: number): number {
|
||||
const channel = (c: number) => {
|
||||
const v = c / 255;
|
||||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
||||
}
|
||||
|
||||
/** WCAG contrast ratio between two hex colors */
|
||||
export function contrastRatio(hex1: string, hex2: string): number {
|
||||
const a = hexToRgb(hex1);
|
||||
const b = hexToRgb(hex2);
|
||||
if (!a || !b) return 1;
|
||||
const l1 = relativeLuminance(...a);
|
||||
const l2 = relativeLuminance(...b);
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/** Pick white or black for best contrast on a given background */
|
||||
export function bestContrastFg(bgHex: string): string {
|
||||
const bg = hexToRgb(bgHex);
|
||||
if (!bg) return "#ffffff";
|
||||
const lum = relativeLuminance(...bg);
|
||||
return lum > 0.55 ? "#0F172A" : "#FFFFFF";
|
||||
}
|
||||
|
||||
/** Produce 8-digit hex with alpha (for "soft" tints used in chips/badges) */
|
||||
export function withAlpha(hex: string, alphaPercent: number): string {
|
||||
const a = Math.round((alphaPercent / 100) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
// Normalize to #RRGGBB
|
||||
const cleaned = hex.replace("#", "");
|
||||
if (cleaned.length === 3) {
|
||||
const full =
|
||||
cleaned[0] + cleaned[0] + cleaned[1] + cleaned[1] + cleaned[2] + cleaned[2];
|
||||
return `#${full}${a}`;
|
||||
}
|
||||
return `#${cleaned}${a}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mix two hex colors at a percentage. `mix(a, b, 30)` = 30% a, 70% b.
|
||||
* Returns #RRGGBB.
|
||||
*/
|
||||
export function mix(a: string, b: string, aPercent: number): string {
|
||||
const ra = hexToRgb(a);
|
||||
const rb = hexToRgb(b);
|
||||
if (!ra || !rb) return a;
|
||||
const p = aPercent / 100;
|
||||
const r = Math.round(ra[0] * p + rb[0] * (1 - p));
|
||||
const g = Math.round(ra[1] * p + rb[1] * (1 - p));
|
||||
const bl = Math.round(ra[2] * p + rb[2] * (1 - p));
|
||||
const toHex = (n: number) => n.toString(16).padStart(2, "0");
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(bl)}`;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Full token derivation
|
||||
*
|
||||
* Given the 7 user-picked colors, compute the full token set (~25 vars)
|
||||
* that shadcn/ui + the theme system expects. All in OKLCH-friendly hex.
|
||||
* ========================================================================== */
|
||||
export function deriveTokens(colors: CustomThemeColors): Record<string, string> {
|
||||
const { primary, accent, destructive, background, sidebar, foreground, border } = colors;
|
||||
|
||||
// Foreground on each colored surface
|
||||
const primaryFg = bestContrastFg(primary);
|
||||
const accentFg = bestContrastFg(accent);
|
||||
const destructiveFg = bestContrastFg(destructive);
|
||||
const sidebarFg = bestContrastFg(sidebar);
|
||||
|
||||
// Slight tints derived from background for card / popover / muted
|
||||
const card = mix("#FFFFFF", background, 2); // barely lighter than bg
|
||||
const muted = mix("#000000", background, 4); // barely darker
|
||||
const mutedFg = mix("#94A3B8", foreground, 60);
|
||||
const secondary = mix("#000000", background, 3);
|
||||
|
||||
// Sidebar accents — slightly tinted sidebar surface for hover/active states
|
||||
const sidebarAccent = mix("#FFFFFF", sidebar, 8);
|
||||
const sidebarBorder = mix("#FFFFFF", sidebar, 12);
|
||||
|
||||
return {
|
||||
"--background": background,
|
||||
"--foreground": foreground,
|
||||
"--card": card,
|
||||
"--card-foreground": foreground,
|
||||
"--popover": card,
|
||||
"--popover-foreground": foreground,
|
||||
"--primary": primary,
|
||||
"--primary-foreground": primaryFg,
|
||||
"--secondary": secondary,
|
||||
"--secondary-foreground": foreground,
|
||||
"--muted": muted,
|
||||
"--muted-foreground": mutedFg,
|
||||
"--accent": accent,
|
||||
"--accent-foreground": accentFg,
|
||||
"--destructive": destructive,
|
||||
"--destructive-foreground": destructiveFg,
|
||||
"--border": border,
|
||||
"--input": border,
|
||||
"--ring": primary,
|
||||
"--sidebar": sidebar,
|
||||
"--sidebar-foreground": sidebarFg,
|
||||
"--sidebar-primary": primary,
|
||||
"--sidebar-primary-foreground": primaryFg,
|
||||
"--sidebar-accent": sidebarAccent,
|
||||
"--sidebar-accent-foreground": sidebarFg,
|
||||
"--sidebar-border": sidebarBorder,
|
||||
"--sidebar-ring": primary,
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Apply custom theme to <html>
|
||||
* ========================================================================== */
|
||||
export function applyCustomThemeToDocument(theme: CustomTheme | null): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
if (!theme) {
|
||||
root.removeAttribute("data-theme");
|
||||
root.style.removeProperty("data-theme");
|
||||
// Clear all --xxx props we previously set
|
||||
const keys = Object.keys(deriveTokens(DEFAULT_CUSTOM_COLORS));
|
||||
for (const k of keys) root.style.removeProperty(k);
|
||||
// Restore radius
|
||||
root.style.removeProperty("--radius");
|
||||
return;
|
||||
}
|
||||
root.setAttribute("data-theme", "custom");
|
||||
const tokens = deriveTokens(theme.colors);
|
||||
for (const [key, value] of Object.entries(tokens)) {
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
root.style.setProperty("--radius", "0.625rem");
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Load + apply persisted custom theme (called from bootstrap script)
|
||||
* ========================================================================== */
|
||||
export function bootstrapCustomTheme(): CustomTheme | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const id = loadActiveCustomThemeId();
|
||||
if (!id) return null;
|
||||
const themes = loadCustomThemes();
|
||||
const theme = themes.find((t) => t.id === id) ?? null;
|
||||
if (theme) applyCustomThemeToDocument(theme);
|
||||
return theme;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Helpers for the modal
|
||||
* ========================================================================== */
|
||||
export function newCustomThemeId(): string {
|
||||
return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export function presetToColors(themeId: string): CustomThemeColors | null {
|
||||
switch (themeId) {
|
||||
case "crisp":
|
||||
return {
|
||||
primary: "#4C96D7",
|
||||
accent: "#F2AF58",
|
||||
destructive: "#E94E49",
|
||||
background: "#F8FAFC",
|
||||
sidebar: "#0F2E4C",
|
||||
foreground: "#0F172A",
|
||||
border: "#E2E8F0",
|
||||
};
|
||||
case "midnight":
|
||||
return {
|
||||
primary: "#4C96D7",
|
||||
accent: "#F2AF58",
|
||||
destructive: "#E94E49",
|
||||
background: "#0B1220",
|
||||
sidebar: "#05080F",
|
||||
foreground: "#E6ECF5",
|
||||
border: "#1F2B47",
|
||||
};
|
||||
case "editorial":
|
||||
return {
|
||||
primary: "#1E5A99",
|
||||
accent: "#B8842C",
|
||||
destructive: "#C0392B",
|
||||
background: "#FBF8F3",
|
||||
sidebar: "#0F2E4C",
|
||||
foreground: "#0F2E4C",
|
||||
border: "#E8DFCB",
|
||||
};
|
||||
case "bento":
|
||||
return {
|
||||
primary: "#4C96D7",
|
||||
accent: "#F2AF58",
|
||||
destructive: "#E94E49",
|
||||
background: "#F4F6FB",
|
||||
sidebar: "#0F2E4C",
|
||||
foreground: "#0F172A",
|
||||
border: "#E2E8F0",
|
||||
};
|
||||
case "field-ops":
|
||||
return {
|
||||
primary: "#0F2E4C",
|
||||
accent: "#F2AF58",
|
||||
destructive: "#E94E49",
|
||||
background: "#F8FAFC",
|
||||
sidebar: "#0F2E4C",
|
||||
foreground: "#0F172A",
|
||||
border: "#E2E8F0",
|
||||
};
|
||||
case "terminal":
|
||||
return {
|
||||
primary: "#7EE787",
|
||||
accent: "#F2AF58",
|
||||
destructive: "#FF7B72",
|
||||
background: "#0D1117",
|
||||
sidebar: "#010409",
|
||||
foreground: "#C9D1D9",
|
||||
border: "#30363D",
|
||||
};
|
||||
case "blueprint":
|
||||
return {
|
||||
primary: "#FFFFFF",
|
||||
accent: "#F2AF58",
|
||||
destructive: "#FF6B6B",
|
||||
background: "#0B3B8C",
|
||||
sidebar: "#082D6B",
|
||||
foreground: "#FFFFFF",
|
||||
border: "#1F5BB8",
|
||||
};
|
||||
case "confetti":
|
||||
return {
|
||||
primary: "#7AB6E5",
|
||||
accent: "#F8D89A",
|
||||
destructive: "#F0A19D",
|
||||
background: "#FFF7F0",
|
||||
sidebar: "#F8D89A",
|
||||
foreground: "#2A3958",
|
||||
border: "#F0DCC2",
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Contrast warnings
|
||||
* ========================================================================== */
|
||||
export type ContrastCheck = {
|
||||
label: string;
|
||||
fg: string;
|
||||
bg: string;
|
||||
ratio: number;
|
||||
/** WCAG AA minimum for normal text is 4.5:1 */
|
||||
passes: boolean;
|
||||
};
|
||||
|
||||
export function getContrastChecks(colors: CustomThemeColors): ContrastCheck[] {
|
||||
const checks: ContrastCheck[] = [
|
||||
{
|
||||
label: "Body text on background",
|
||||
fg: colors.foreground,
|
||||
bg: colors.background,
|
||||
ratio: contrastRatio(colors.foreground, colors.background),
|
||||
passes: false,
|
||||
},
|
||||
{
|
||||
label: "Primary button text",
|
||||
fg: bestContrastFg(colors.primary),
|
||||
bg: colors.primary,
|
||||
ratio: contrastRatio(bestContrastFg(colors.primary), colors.primary),
|
||||
passes: false,
|
||||
},
|
||||
{
|
||||
label: "Sidebar text on sidebar",
|
||||
fg: bestContrastFg(colors.sidebar),
|
||||
bg: colors.sidebar,
|
||||
ratio: contrastRatio(bestContrastFg(colors.sidebar), colors.sidebar),
|
||||
passes: false,
|
||||
},
|
||||
{
|
||||
label: "Accent button text",
|
||||
fg: bestContrastFg(colors.accent),
|
||||
bg: colors.accent,
|
||||
ratio: contrastRatio(bestContrastFg(colors.accent), colors.accent),
|
||||
passes: false,
|
||||
},
|
||||
];
|
||||
for (const c of checks) c.passes = c.ratio >= 4.5;
|
||||
return checks;
|
||||
}
|
||||
|
||||
/** Suggest an auto-fixed foreground for a given background */
|
||||
export function autoFixForeground(bgHex: string): string {
|
||||
// Walk lightness up/down to find a foreground that hits 4.5:1
|
||||
const bg = hexToRgb(bgHex);
|
||||
if (!bg) return "#FFFFFF";
|
||||
const candidates = [
|
||||
"#FFFFFF",
|
||||
"#0F172A",
|
||||
"#F8FAFC",
|
||||
"#E2E8F0",
|
||||
"#1E293B",
|
||||
"#94A3B8",
|
||||
];
|
||||
let best = candidates[0];
|
||||
let bestRatio = 0;
|
||||
for (const c of candidates) {
|
||||
const r = contrastRatio(bgHex, c);
|
||||
if (r > bestRatio) {
|
||||
bestRatio = r;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
|
@ -55,6 +55,32 @@ export async function getAllSuppliers() {
|
|||
return pb.collection("suppliers").getFullList<Supplier>({ sort: "-created" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields the supplier-map actually renders. Kept narrow so a 5k-row
|
||||
* supplier table doesn't ship `closingtime` and the rest of the blob
|
||||
* over the wire just to show pins on a Leaflet map.
|
||||
*/
|
||||
const SUPPLIER_MAP_FIELDS =
|
||||
"id,suppliername,supplieraddress,suppliernumber,email,supply_tags,coordinates";
|
||||
|
||||
/**
|
||||
* Suppliers that can actually be placed on the map — only rows with
|
||||
* non-empty `coordinates`, and only the fields the map renders.
|
||||
*
|
||||
* Distinct from `getAllSuppliers` (which the quote/order forms still
|
||||
* want full records from). Introduced when the supplier-map was
|
||||
* choking on zoom-in with a few thousand suppliers: shipping the
|
||||
* whole table was the biggest single contributor.
|
||||
*/
|
||||
export async function getMappableSuppliers() {
|
||||
const pb = getPocketBase();
|
||||
return pb.collection("suppliers").getFullList<Supplier>({
|
||||
sort: "-created",
|
||||
filter: 'coordinates != ""',
|
||||
fields: SUPPLIER_MAP_FIELDS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote queries reference `is_template`, `quote_date`, `validity_days`,
|
||||
* `follow_up_due`, `template_source_id`, `generated_from_template_id` —
|
||||
|
|
|
|||
139
src/lib/themes.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* WFDASH theme registry.
|
||||
*
|
||||
* Each theme is identified by an `id` used in the `data-theme` attribute
|
||||
* on <html>. Themes compose with the warm/dark mode toggle (handled by
|
||||
* next-themes via the `class` attribute on <html>).
|
||||
*
|
||||
* The 5 "safe" themes work cleanly with shadcn/ui out of the box.
|
||||
* The 3 "standout" themes are intentionally creative — they may need
|
||||
* extra component overrides for dialogs/popovers/tooltips.
|
||||
*/
|
||||
|
||||
export type ThemeCategory = "standard" | "standout";
|
||||
|
||||
export type ThemeDefinition = {
|
||||
id: string;
|
||||
label: string;
|
||||
category: ThemeCategory;
|
||||
tagline: string;
|
||||
/** Fonts are CSS variable names registered via next/font in layout.tsx */
|
||||
fontHeading: string;
|
||||
fontBody: string;
|
||||
/** Tailwind radius token used for the `rounded-*` scale */
|
||||
radius: string;
|
||||
/** Preview swatches for the settings picker */
|
||||
swatches: { primary: string; gold: string; red: string; surface: string; text: string };
|
||||
};
|
||||
|
||||
export const THEMES: ThemeDefinition[] = [
|
||||
// ============================================================
|
||||
// 5 SAFE / STANDARD THEMES
|
||||
// ============================================================
|
||||
{
|
||||
id: "crisp",
|
||||
label: "Crisp Minimal",
|
||||
category: "standard",
|
||||
tagline: "Clean, calm, professional. Easiest to ship.",
|
||||
fontHeading: "var(--font-inter)",
|
||||
fontBody: "var(--font-inter)",
|
||||
radius: "0.625rem",
|
||||
swatches: { primary: "#4C96D7", gold: "#F2AF58", red: "#E94E49", surface: "#FFFFFF", text: "#0F172A" },
|
||||
},
|
||||
{
|
||||
id: "midnight",
|
||||
label: "Midnight Sapphire",
|
||||
category: "standard",
|
||||
tagline: "Dark by default. Control-room vibes, low eye-strain.",
|
||||
fontHeading: "var(--font-space-grotesk)",
|
||||
fontBody: "var(--font-dm-sans)",
|
||||
radius: "0.75rem",
|
||||
swatches: { primary: "#4C96D7", gold: "#F2AF58", red: "#E94E49", surface: "#0B1220", text: "#E6ECF5" },
|
||||
},
|
||||
{
|
||||
id: "editorial",
|
||||
label: "Editorial Premium",
|
||||
category: "standard",
|
||||
tagline: "Warm cream + serif headings. Financial quarterly feel.",
|
||||
fontHeading: "var(--font-playfair)",
|
||||
fontBody: "var(--font-lato)",
|
||||
radius: "0.375rem",
|
||||
swatches: { primary: "#1E5A99", gold: "#B8842C", red: "#C0392B", surface: "#FBF8F3", text: "#0F2E4C" },
|
||||
},
|
||||
{
|
||||
id: "bento",
|
||||
label: "Bento Modular",
|
||||
category: "standard",
|
||||
tagline: "Tinted tiles, big radius. KPI-heavy dashboards pop.",
|
||||
fontHeading: "var(--font-inter)",
|
||||
fontBody: "var(--font-inter)",
|
||||
radius: "1.125rem",
|
||||
swatches: { primary: "#4C96D7", gold: "#F2AF58", red: "#E94E49", surface: "#F4F6FB", text: "#0F172A" },
|
||||
},
|
||||
{
|
||||
id: "field-ops",
|
||||
label: "Field Ops",
|
||||
category: "standard",
|
||||
tagline: "Dense, glove-friendly. Tablet in the yard.",
|
||||
fontHeading: "var(--font-inter)",
|
||||
fontBody: "var(--font-inter)",
|
||||
radius: "0.5rem",
|
||||
swatches: { primary: "#0F2E4C", gold: "#F2AF58", red: "#E94E49", surface: "#F8FAFC", text: "#0F172A" },
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// 3 STANDOUT / CREATIVE THEMES
|
||||
// ============================================================
|
||||
{
|
||||
id: "terminal",
|
||||
label: "Terminal",
|
||||
category: "standout",
|
||||
tagline: "Green CRT. Monospace. For the spreadsheet crowd.",
|
||||
fontHeading: "var(--font-jetbrains-mono)",
|
||||
fontBody: "var(--font-jetbrains-mono)",
|
||||
radius: "0",
|
||||
swatches: { primary: "#7EE787", gold: "#F2AF58", red: "#FF7B72", surface: "#0D1117", text: "#C9D1D9" },
|
||||
},
|
||||
{
|
||||
id: "blueprint",
|
||||
label: "Blueprint",
|
||||
category: "standout",
|
||||
tagline: "Engineering blueprint. White lines on technical blue.",
|
||||
fontHeading: "var(--font-jetbrains-mono)",
|
||||
fontBody: "var(--font-inter)",
|
||||
radius: "0.125rem",
|
||||
swatches: { primary: "#FFFFFF", gold: "#F2AF58", red: "#FF6B6B", surface: "#0B3B8C", text: "#FFFFFF" },
|
||||
},
|
||||
{
|
||||
id: "confetti",
|
||||
label: "Confetti",
|
||||
category: "standout",
|
||||
tagline: "Playful pastels. Oversized radius. Friendly for everyone.",
|
||||
fontHeading: "var(--font-outfit)",
|
||||
fontBody: "var(--font-nunito)",
|
||||
radius: "1.25rem",
|
||||
swatches: { primary: "#7AB6E5", gold: "#F8D89A", red: "#F0A19D", surface: "#FFF7F0", text: "#2A3958" },
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_THEME_ID = "crisp";
|
||||
|
||||
export function getThemeById(id: string | null | undefined): ThemeDefinition {
|
||||
return THEMES.find((t) => t.id === id) ?? THEMES[0];
|
||||
}
|
||||
|
||||
export const STORAGE_KEY_THEME = "wfdash-theme";
|
||||
|
||||
/** Apply theme + mode to <html>. Called before hydration to avoid FOUC. */
|
||||
export function applyThemeToDocument(themeId: string, mode: "warm" | "dark"): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
root.setAttribute("data-theme", themeId);
|
||||
root.classList.remove("warm", "dark");
|
||||
root.classList.add(mode);
|
||||
// Map theme semantic CSS vars onto the html root so shadcn picks them up
|
||||
const t = getThemeById(themeId);
|
||||
root.style.setProperty("--font-heading", t.fontHeading);
|
||||
root.style.setProperty("--font-body", t.fontBody);
|
||||
root.style.setProperty("--radius", t.radius);
|
||||
}
|
||||
49
src/lib/use-theme-id.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
DEFAULT_THEME_ID,
|
||||
getThemeById,
|
||||
} from "@/lib/themes";
|
||||
|
||||
/**
|
||||
* useThemeId — reads / writes the active visual theme id.
|
||||
*
|
||||
* The theme id is applied to <html> via the `data-theme` attribute
|
||||
* and the matching OKLCH tokens in src/styles/themes.css do the rest.
|
||||
*
|
||||
* Persists to localStorage so the bootstrap script can re-apply
|
||||
* before hydration on the next visit (prevents FOUC).
|
||||
*
|
||||
* Initial state reads localStorage lazily so we don't trigger a cascading
|
||||
* render — the bootstrap script has already set `data-theme` before React
|
||||
* hydrates, so the first render matches.
|
||||
*/
|
||||
export function useThemeId() {
|
||||
const [themeId, setThemeIdState] = useState<string>(() => {
|
||||
if (typeof window === "undefined") return DEFAULT_THEME_ID;
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY_THEME) ?? DEFAULT_THEME_ID;
|
||||
} catch {
|
||||
return DEFAULT_THEME_ID;
|
||||
}
|
||||
});
|
||||
|
||||
const setThemeId = useCallback((id: string) => {
|
||||
setThemeIdState(id);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_THEME, id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
const root = document.documentElement;
|
||||
root.setAttribute("data-theme", id);
|
||||
const def = getThemeById(id);
|
||||
root.style.setProperty("--radius", def.radius);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { themeId, setThemeId };
|
||||
}
|
||||
580
src/styles/themes.css
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
/* ============================================================================
|
||||
* WFDASH — Multi-theme tokens
|
||||
*
|
||||
* 5 standard + 3 standout themes, each with a light (default) + dark variant.
|
||||
* Apply by setting `data-theme="<id>"` on <html>; mode is controlled separately
|
||||
* via the `.dark` or `.warm` class (managed by next-themes).
|
||||
*
|
||||
* Brand palette (locked):
|
||||
* primary #4C96D7 oklch(0.62 0.13 240)
|
||||
* gold #F2AF58 oklch(0.81 0.14 75)
|
||||
* red #E94E49 oklch(0.62 0.21 25)
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
/* ============================================================================
|
||||
* 01 · CRISP MINIMAL — default safe theme
|
||||
* ============================================================================ */
|
||||
html[data-theme="crisp"] {
|
||||
--background: oklch(0.985 0.01 240);
|
||||
--foreground: oklch(0.20 0.045 240);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.20 0.045 240);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.20 0.045 240);
|
||||
--primary: oklch(0.62 0.13 240);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.96 0.018 240);
|
||||
--secondary-foreground: oklch(0.24 0.08 240);
|
||||
--muted: oklch(0.96 0.012 240);
|
||||
--muted-foreground: oklch(0.48 0.035 240);
|
||||
--accent: oklch(0.81 0.14 75);
|
||||
--accent-foreground: oklch(0.28 0.08 240);
|
||||
--destructive: oklch(0.62 0.21 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.90 0.018 240);
|
||||
--input: oklch(0.92 0.018 240);
|
||||
--ring: oklch(0.62 0.13 240);
|
||||
--sidebar: oklch(0.97 0.015 240);
|
||||
--sidebar-foreground: oklch(0.20 0.045 240);
|
||||
--sidebar-primary: oklch(0.62 0.13 240);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.94 0.04 240);
|
||||
--sidebar-accent-foreground: oklch(0.20 0.045 240);
|
||||
--sidebar-border: oklch(0.88 0.025 240);
|
||||
--sidebar-ring: oklch(0.62 0.13 240);
|
||||
}
|
||||
html[data-theme="crisp"].dark {
|
||||
--background: oklch(0.15 0.035 240);
|
||||
--foreground: oklch(0.96 0.01 240);
|
||||
--card: oklch(0.18 0.035 240);
|
||||
--card-foreground: oklch(0.96 0.01 240);
|
||||
--popover: oklch(0.18 0.035 240);
|
||||
--popover-foreground: oklch(0.96 0.01 240);
|
||||
--primary: oklch(0.72 0.13 240);
|
||||
--primary-foreground: oklch(0.12 0.03 240);
|
||||
--secondary: oklch(0.24 0.045 240);
|
||||
--secondary-foreground: oklch(0.96 0.01 240);
|
||||
--muted: oklch(0.23 0.035 240);
|
||||
--muted-foreground: oklch(0.66 0.035 240);
|
||||
--accent: oklch(0.78 0.14 75);
|
||||
--accent-foreground: oklch(0.15 0.035 240);
|
||||
--destructive: oklch(0.68 0.21 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.30 0.04 240);
|
||||
--input: oklch(0.30 0.04 240);
|
||||
--ring: oklch(0.72 0.13 240);
|
||||
--sidebar: oklch(0.16 0.04 240);
|
||||
--sidebar-foreground: oklch(0.96 0.01 240);
|
||||
--sidebar-primary: oklch(0.72 0.13 240);
|
||||
--sidebar-primary-foreground: oklch(0.12 0.03 240);
|
||||
--sidebar-accent: oklch(0.22 0.045 240);
|
||||
--sidebar-accent-foreground: oklch(0.96 0.01 240);
|
||||
--sidebar-border: oklch(0.30 0.04 240);
|
||||
--sidebar-ring: oklch(0.72 0.13 240);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 02 · MIDNIGHT SAPPHIRE — dark by default, electric blue accents
|
||||
* ============================================================================ */
|
||||
html[data-theme="midnight"] {
|
||||
--background: oklch(0.14 0.035 240);
|
||||
--foreground: oklch(0.93 0.01 240);
|
||||
--card: oklch(0.18 0.04 240);
|
||||
--card-foreground: oklch(0.93 0.01 240);
|
||||
--popover: oklch(0.18 0.04 240);
|
||||
--popover-foreground: oklch(0.93 0.01 240);
|
||||
--primary: oklch(0.68 0.16 240);
|
||||
--primary-foreground: oklch(0.10 0.03 240);
|
||||
--secondary: oklch(0.22 0.05 240);
|
||||
--secondary-foreground: oklch(0.93 0.01 240);
|
||||
--muted: oklch(0.22 0.04 240);
|
||||
--muted-foreground: oklch(0.66 0.04 240);
|
||||
--accent: oklch(0.81 0.14 75);
|
||||
--accent-foreground: oklch(0.12 0.04 240);
|
||||
--destructive: oklch(0.66 0.22 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.26 0.05 240);
|
||||
--input: oklch(0.24 0.05 240);
|
||||
--ring: oklch(0.68 0.16 240);
|
||||
--sidebar: oklch(0.11 0.03 240);
|
||||
--sidebar-foreground: oklch(0.93 0.01 240);
|
||||
--sidebar-primary: oklch(0.68 0.16 240);
|
||||
--sidebar-primary-foreground: oklch(0.10 0.03 240);
|
||||
--sidebar-accent: oklch(0.18 0.045 240);
|
||||
--sidebar-accent-foreground: oklch(0.93 0.01 240);
|
||||
--sidebar-border: oklch(0.22 0.05 240);
|
||||
--sidebar-ring: oklch(0.68 0.16 240);
|
||||
}
|
||||
html[data-theme="midnight"].dark {
|
||||
/* Midnight stays dark — just slightly deeper */
|
||||
--background: oklch(0.10 0.025 240);
|
||||
--foreground: oklch(0.96 0.008 240);
|
||||
--card: oklch(0.13 0.03 240);
|
||||
--border: oklch(0.22 0.04 240);
|
||||
--sidebar: oklch(0.08 0.025 240);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 03 · EDITORIAL PREMIUM — warm cream, serif headings, deep navy
|
||||
* ============================================================================ */
|
||||
html[data-theme="editorial"] {
|
||||
--background: oklch(0.975 0.018 80);
|
||||
--foreground: oklch(0.22 0.05 240);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.22 0.05 240);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.22 0.05 240);
|
||||
--primary: oklch(0.42 0.13 240); /* deeper blue for serif contrast */
|
||||
--primary-foreground: oklch(0.99 0.005 80);
|
||||
--secondary: oklch(0.95 0.025 80);
|
||||
--secondary-foreground: oklch(0.24 0.07 240);
|
||||
--muted: oklch(0.94 0.022 80);
|
||||
--muted-foreground: oklch(0.45 0.04 240);
|
||||
--accent: oklch(0.62 0.13 75); /* burnt gold, not bright */
|
||||
--accent-foreground: oklch(1 0 0);
|
||||
--destructive: oklch(0.50 0.20 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.88 0.03 80);
|
||||
--input: oklch(0.91 0.03 80);
|
||||
--ring: oklch(0.42 0.13 240);
|
||||
--sidebar: oklch(0.95 0.022 80);
|
||||
--sidebar-foreground: oklch(0.22 0.05 240);
|
||||
--sidebar-primary: oklch(0.42 0.13 240);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.92 0.04 80);
|
||||
--sidebar-accent-foreground: oklch(0.22 0.05 240);
|
||||
--sidebar-border: oklch(0.86 0.035 80);
|
||||
--sidebar-ring: oklch(0.42 0.13 240);
|
||||
}
|
||||
html[data-theme="editorial"].dark {
|
||||
--background: oklch(0.16 0.025 240);
|
||||
--foreground: oklch(0.94 0.01 80);
|
||||
--card: oklch(0.19 0.03 240);
|
||||
--card-foreground: oklch(0.94 0.01 80);
|
||||
--popover: oklch(0.19 0.03 240);
|
||||
--popover-foreground: oklch(0.94 0.01 80);
|
||||
--primary: oklch(0.70 0.13 240);
|
||||
--primary-foreground: oklch(0.10 0.03 240);
|
||||
--secondary: oklch(0.25 0.04 240);
|
||||
--secondary-foreground: oklch(0.94 0.01 80);
|
||||
--muted: oklch(0.24 0.035 240);
|
||||
--muted-foreground: oklch(0.68 0.035 240);
|
||||
--accent: oklch(0.76 0.14 75);
|
||||
--accent-foreground: oklch(0.10 0.03 240);
|
||||
--destructive: oklch(0.66 0.20 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.30 0.04 240);
|
||||
--input: oklch(0.30 0.04 240);
|
||||
--ring: oklch(0.70 0.13 240);
|
||||
--sidebar: oklch(0.14 0.025 240);
|
||||
--sidebar-foreground: oklch(0.94 0.01 80);
|
||||
--sidebar-primary: oklch(0.70 0.13 240);
|
||||
--sidebar-primary-foreground: oklch(0.10 0.03 240);
|
||||
--sidebar-accent: oklch(0.22 0.04 240);
|
||||
--sidebar-accent-foreground: oklch(0.94 0.01 80);
|
||||
--sidebar-border: oklch(0.30 0.04 240);
|
||||
--sidebar-ring: oklch(0.70 0.13 240);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 04 · BENTO MODULAR — cool tinted base, big radius
|
||||
* ============================================================================ */
|
||||
html[data-theme="bento"] {
|
||||
--background: oklch(0.965 0.018 240);
|
||||
--foreground: oklch(0.18 0.045 240);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.18 0.045 240);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.18 0.045 240);
|
||||
--primary: oklch(0.62 0.13 240);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.93 0.035 240);
|
||||
--secondary-foreground: oklch(0.24 0.08 240);
|
||||
--muted: oklch(0.94 0.025 240);
|
||||
--muted-foreground: oklch(0.48 0.035 240);
|
||||
--accent: oklch(0.81 0.14 75);
|
||||
--accent-foreground: oklch(0.24 0.08 240);
|
||||
--destructive: oklch(0.62 0.21 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.88 0.025 240);
|
||||
--input: oklch(0.92 0.025 240);
|
||||
--ring: oklch(0.62 0.13 240);
|
||||
--sidebar: oklch(0.95 0.028 240);
|
||||
--sidebar-foreground: oklch(0.18 0.045 240);
|
||||
--sidebar-primary: oklch(0.62 0.13 240);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.91 0.05 240);
|
||||
--sidebar-accent-foreground: oklch(0.18 0.045 240);
|
||||
--sidebar-border: oklch(0.86 0.03 240);
|
||||
--sidebar-ring: oklch(0.62 0.13 240);
|
||||
}
|
||||
html[data-theme="bento"].dark {
|
||||
--background: oklch(0.14 0.035 240);
|
||||
--foreground: oklch(0.95 0.01 240);
|
||||
--card: oklch(0.17 0.04 240);
|
||||
--card-foreground: oklch(0.95 0.01 240);
|
||||
--popover: oklch(0.17 0.04 240);
|
||||
--popover-foreground: oklch(0.95 0.01 240);
|
||||
--primary: oklch(0.70 0.13 240);
|
||||
--primary-foreground: oklch(0.10 0.03 240);
|
||||
--secondary: oklch(0.22 0.05 240);
|
||||
--secondary-foreground: oklch(0.95 0.01 240);
|
||||
--muted: oklch(0.22 0.04 240);
|
||||
--muted-foreground: oklch(0.66 0.035 240);
|
||||
--accent: oklch(0.78 0.14 75);
|
||||
--accent-foreground: oklch(0.10 0.03 240);
|
||||
--destructive: oklch(0.66 0.20 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.28 0.04 240);
|
||||
--input: oklch(0.28 0.04 240);
|
||||
--ring: oklch(0.70 0.13 240);
|
||||
--sidebar: oklch(0.13 0.035 240);
|
||||
--sidebar-foreground: oklch(0.95 0.01 240);
|
||||
--sidebar-primary: oklch(0.70 0.13 240);
|
||||
--sidebar-primary-foreground: oklch(0.10 0.03 240);
|
||||
--sidebar-accent: oklch(0.20 0.045 240);
|
||||
--sidebar-accent-foreground: oklch(0.95 0.01 240);
|
||||
--sidebar-border: oklch(0.28 0.04 240);
|
||||
--sidebar-ring: oklch(0.70 0.13 240);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 05 · FIELD OPS — deep navy sidebar + gold CTAs, dense
|
||||
* ============================================================================ */
|
||||
html[data-theme="field-ops"] {
|
||||
--background: oklch(0.98 0.008 240);
|
||||
--foreground: oklch(0.18 0.04 240);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.18 0.04 240);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.18 0.04 240);
|
||||
--primary: oklch(0.28 0.07 240); /* deep navy */
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.95 0.012 240);
|
||||
--secondary-foreground: oklch(0.20 0.06 240);
|
||||
--muted: oklch(0.95 0.012 240);
|
||||
--muted-foreground: oklch(0.48 0.03 240);
|
||||
--accent: oklch(0.81 0.14 75);
|
||||
--accent-foreground: oklch(0.18 0.05 240);
|
||||
--destructive: oklch(0.62 0.21 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.90 0.015 240);
|
||||
--input: oklch(0.92 0.015 240);
|
||||
--ring: oklch(0.28 0.07 240);
|
||||
--sidebar: oklch(0.22 0.06 240); /* deep navy sidebar */
|
||||
--sidebar-foreground: oklch(0.96 0.008 240);
|
||||
--sidebar-primary: oklch(0.81 0.14 75); /* gold as primary in sidebar */
|
||||
--sidebar-primary-foreground: oklch(0.18 0.05 240);
|
||||
--sidebar-accent: oklch(0.28 0.07 240);
|
||||
--sidebar-accent-foreground: oklch(0.96 0.008 240);
|
||||
--sidebar-border: oklch(0.30 0.06 240);
|
||||
--sidebar-ring: oklch(0.81 0.14 75);
|
||||
}
|
||||
html[data-theme="field-ops"].dark {
|
||||
--background: oklch(0.13 0.025 240);
|
||||
--foreground: oklch(0.95 0.008 240);
|
||||
--card: oklch(0.16 0.03 240);
|
||||
--card-foreground: oklch(0.95 0.008 240);
|
||||
--popover: oklch(0.16 0.03 240);
|
||||
--popover-foreground: oklch(0.95 0.008 240);
|
||||
--primary: oklch(0.20 0.06 240);
|
||||
--primary-foreground: oklch(0.95 0.008 240);
|
||||
--secondary: oklch(0.22 0.04 240);
|
||||
--secondary-foreground: oklch(0.95 0.008 240);
|
||||
--muted: oklch(0.22 0.04 240);
|
||||
--muted-foreground: oklch(0.66 0.03 240);
|
||||
--accent: oklch(0.78 0.14 75);
|
||||
--accent-foreground: oklch(0.13 0.025 240);
|
||||
--destructive: oklch(0.66 0.20 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.28 0.04 240);
|
||||
--input: oklch(0.28 0.04 240);
|
||||
--ring: oklch(0.78 0.14 75);
|
||||
--sidebar: oklch(0.10 0.04 240);
|
||||
--sidebar-foreground: oklch(0.95 0.008 240);
|
||||
--sidebar-primary: oklch(0.78 0.14 75);
|
||||
--sidebar-primary-foreground: oklch(0.10 0.04 240);
|
||||
--sidebar-accent: oklch(0.18 0.05 240);
|
||||
--sidebar-accent-foreground: oklch(0.95 0.008 240);
|
||||
--sidebar-border: oklch(0.24 0.05 240);
|
||||
--sidebar-ring: oklch(0.78 0.14 75);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 06 · TERMINAL — green CRT, monospace, 0px radius
|
||||
* ============================================================================ */
|
||||
html[data-theme="terminal"] {
|
||||
--background: oklch(0.16 0.018 145);
|
||||
--foreground: oklch(0.92 0.04 145);
|
||||
--card: oklch(0.13 0.02 145);
|
||||
--card-foreground: oklch(0.92 0.04 145);
|
||||
--popover: oklch(0.13 0.02 145);
|
||||
--popover-foreground: oklch(0.92 0.04 145);
|
||||
--primary: oklch(0.84 0.18 145); /* terminal green */
|
||||
--primary-foreground: oklch(0.10 0.02 145);
|
||||
--secondary: oklch(0.20 0.025 145);
|
||||
--secondary-foreground: oklch(0.92 0.04 145);
|
||||
--muted: oklch(0.20 0.022 145);
|
||||
--muted-foreground: oklch(0.66 0.06 145);
|
||||
--accent: oklch(0.81 0.14 75);
|
||||
--accent-foreground: oklch(0.10 0.02 145);
|
||||
--destructive: oklch(0.66 0.22 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.30 0.05 145);
|
||||
--input: oklch(0.22 0.04 145);
|
||||
--ring: oklch(0.84 0.18 145);
|
||||
--sidebar: oklch(0.10 0.02 145);
|
||||
--sidebar-foreground: oklch(0.92 0.04 145);
|
||||
--sidebar-primary: oklch(0.84 0.18 145);
|
||||
--sidebar-primary-foreground: oklch(0.10 0.02 145);
|
||||
--sidebar-accent: oklch(0.16 0.025 145);
|
||||
--sidebar-accent-foreground: oklch(0.92 0.04 145);
|
||||
--sidebar-border: oklch(0.26 0.05 145);
|
||||
--sidebar-ring: oklch(0.84 0.18 145);
|
||||
}
|
||||
html[data-theme="terminal"].dark {
|
||||
--background: oklch(0.12 0.015 145);
|
||||
--foreground: oklch(0.94 0.04 145);
|
||||
--border: oklch(0.26 0.04 145);
|
||||
--card: oklch(0.10 0.018 145);
|
||||
--sidebar: oklch(0.07 0.012 145);
|
||||
}
|
||||
|
||||
/* Terminal-specific styling — ASCII borders, mono everywhere */
|
||||
[data-theme="terminal"] body {
|
||||
font-family: var(--font-body);
|
||||
background-image:
|
||||
repeating-linear-gradient(0deg, transparent, transparent 2px, color-mix(in oklch, var(--primary) 4%, transparent) 2px, color-mix(in oklch, var(--primary) 4%, transparent) 3px);
|
||||
}
|
||||
[data-theme="terminal"] button,
|
||||
[data-theme="terminal"] [role="button"] {
|
||||
border-radius: 0 !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 700;
|
||||
}
|
||||
[data-theme="terminal"] input,
|
||||
[data-theme="terminal"] select,
|
||||
[data-theme="terminal"] textarea {
|
||||
border-radius: 0 !important;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
[data-theme="terminal"] h1,
|
||||
[data-theme="terminal"] h2,
|
||||
[data-theme="terminal"] h3,
|
||||
[data-theme="terminal"] h4 {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 07 · BLUEPRINT — engineering blueprint, white on technical blue
|
||||
* ============================================================================ */
|
||||
html[data-theme="blueprint"] {
|
||||
--background: oklch(0.30 0.13 255); /* deep blueprint blue */
|
||||
--foreground: oklch(0.98 0.005 255);
|
||||
--card: oklch(0.28 0.13 255);
|
||||
--card-foreground: oklch(0.98 0.005 255);
|
||||
--popover: oklch(0.28 0.13 255);
|
||||
--popover-foreground: oklch(0.98 0.005 255);
|
||||
--primary: oklch(0.98 0.005 255); /* white as primary */
|
||||
--primary-foreground: oklch(0.28 0.13 255);
|
||||
--secondary: oklch(0.36 0.13 255);
|
||||
--secondary-foreground: oklch(0.98 0.005 255);
|
||||
--muted: oklch(0.36 0.13 255);
|
||||
--muted-foreground: oklch(0.82 0.04 255);
|
||||
--accent: oklch(0.81 0.14 75); /* gold as accent */
|
||||
--accent-foreground: oklch(0.20 0.06 255);
|
||||
--destructive: oklch(0.68 0.22 25);
|
||||
--destructive-foreground: oklch(0.98 0.005 255);
|
||||
--border: oklch(0.55 0.10 255);
|
||||
--input: oklch(0.40 0.12 255);
|
||||
--ring: oklch(0.98 0.005 255);
|
||||
--sidebar: oklch(0.24 0.13 255);
|
||||
--sidebar-foreground: oklch(0.98 0.005 255);
|
||||
--sidebar-primary: oklch(0.98 0.005 255);
|
||||
--sidebar-primary-foreground: oklch(0.24 0.13 255);
|
||||
--sidebar-accent: oklch(0.32 0.13 255);
|
||||
--sidebar-accent-foreground: oklch(0.98 0.005 255);
|
||||
--sidebar-border: oklch(0.50 0.10 255);
|
||||
--sidebar-ring: oklch(0.98 0.005 255);
|
||||
}
|
||||
html[data-theme="blueprint"].dark {
|
||||
--background: oklch(0.22 0.13 255);
|
||||
--card: oklch(0.20 0.13 255);
|
||||
--sidebar: oklch(0.18 0.13 255);
|
||||
}
|
||||
|
||||
/* Blueprint-specific — grid lines, white text, technical feel */
|
||||
[data-theme="blueprint"] body {
|
||||
background-image:
|
||||
linear-gradient(to right, color-mix(in oklch, var(--foreground) 6%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, color-mix(in oklch, var(--foreground) 6%, transparent) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
[data-theme="blueprint"] button,
|
||||
[data-theme="blueprint"] [role="button"] {
|
||||
border: 1px solid var(--foreground) !important;
|
||||
border-radius: 0 !important;
|
||||
font-family: var(--font-body);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
[data-theme="blueprint"] input,
|
||||
[data-theme="blueprint"] select,
|
||||
[data-theme="blueprint"] textarea {
|
||||
border: 1px solid var(--foreground) !important;
|
||||
border-radius: 0 !important;
|
||||
font-family: var(--font-body);
|
||||
background: transparent;
|
||||
}
|
||||
[data-theme="blueprint"] h1,
|
||||
[data-theme="blueprint"] h2,
|
||||
[data-theme="blueprint"] h3 {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 08 · CONFETTI — playful pastels, oversized radius
|
||||
* ============================================================================ */
|
||||
html[data-theme="confetti"] {
|
||||
--background: oklch(0.985 0.025 60);
|
||||
--foreground: oklch(0.25 0.05 260);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.25 0.05 260);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.25 0.05 260);
|
||||
--primary: oklch(0.74 0.11 230); /* soft blue */
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.94 0.04 60);
|
||||
--secondary-foreground: oklch(0.30 0.06 260);
|
||||
--muted: oklch(0.95 0.03 60);
|
||||
--muted-foreground: oklch(0.52 0.04 260);
|
||||
--accent: oklch(0.86 0.10 75); /* pale gold */
|
||||
--accent-foreground: oklch(0.30 0.08 260);
|
||||
--destructive: oklch(0.72 0.14 25); /* soft coral */
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.92 0.03 60);
|
||||
--input: oklch(0.94 0.03 60);
|
||||
--ring: oklch(0.74 0.11 230);
|
||||
--sidebar: oklch(0.96 0.04 60);
|
||||
--sidebar-foreground: oklch(0.25 0.05 260);
|
||||
--sidebar-primary: oklch(0.74 0.11 230);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.93 0.05 60);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.05 260);
|
||||
--sidebar-border: oklch(0.90 0.04 60);
|
||||
--sidebar-ring: oklch(0.74 0.11 230);
|
||||
}
|
||||
html[data-theme="confetti"].dark {
|
||||
--background: oklch(0.20 0.04 260);
|
||||
--foreground: oklch(0.95 0.025 60);
|
||||
--card: oklch(0.23 0.05 260);
|
||||
--card-foreground: oklch(0.95 0.025 60);
|
||||
--popover: oklch(0.23 0.05 260);
|
||||
--popover-foreground: oklch(0.95 0.025 60);
|
||||
--primary: oklch(0.76 0.12 230);
|
||||
--primary-foreground: oklch(0.15 0.03 260);
|
||||
--secondary: oklch(0.28 0.05 260);
|
||||
--secondary-foreground: oklch(0.95 0.025 60);
|
||||
--muted: oklch(0.28 0.05 260);
|
||||
--muted-foreground: oklch(0.72 0.04 260);
|
||||
--accent: oklch(0.82 0.12 75);
|
||||
--accent-foreground: oklch(0.15 0.03 260);
|
||||
--destructive: oklch(0.74 0.15 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.34 0.05 260);
|
||||
--input: oklch(0.34 0.05 260);
|
||||
--ring: oklch(0.76 0.12 230);
|
||||
--sidebar: oklch(0.22 0.05 260);
|
||||
--sidebar-foreground: oklch(0.95 0.025 60);
|
||||
--sidebar-primary: oklch(0.76 0.12 230);
|
||||
--sidebar-primary-foreground: oklch(0.15 0.03 260);
|
||||
--sidebar-accent: oklch(0.26 0.05 260);
|
||||
--sidebar-accent-foreground: oklch(0.95 0.025 60);
|
||||
--sidebar-border: oklch(0.34 0.05 260);
|
||||
--sidebar-ring: oklch(0.76 0.12 230);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Theme-aware font bindings
|
||||
* (Tailwind 4 reads these via --font-heading / --font-body classes below)
|
||||
* ============================================================================ */
|
||||
html[data-theme="crisp"],
|
||||
html[data-theme="bento"],
|
||||
html[data-theme="field-ops"] {
|
||||
--font-heading: var(--font-inter);
|
||||
--font-body: var(--font-inter);
|
||||
}
|
||||
html[data-theme="midnight"] {
|
||||
--font-heading: var(--font-space-grotesk);
|
||||
--font-body: var(--font-dm-sans);
|
||||
}
|
||||
html[data-theme="editorial"] {
|
||||
--font-heading: var(--font-playfair);
|
||||
--font-body: var(--font-lato);
|
||||
}
|
||||
html[data-theme="terminal"] {
|
||||
--font-heading: var(--font-jetbrains-mono);
|
||||
--font-body: var(--font-jetbrains-mono);
|
||||
}
|
||||
html[data-theme="blueprint"] {
|
||||
--font-heading: var(--font-jetbrains-mono);
|
||||
--font-body: var(--font-inter);
|
||||
}
|
||||
html[data-theme="confetti"] {
|
||||
--font-heading: var(--font-outfit);
|
||||
--font-body: var(--font-nunito);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Helper classes that read theme tokens
|
||||
* ============================================================================ */
|
||||
.font-heading-theme { font-family: var(--font-heading); }
|
||||
.font-body-theme { font-family: var(--font-body); }
|
||||
|
||||
/* Body font follows theme */
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-heading);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Body keeps the brand gradient wash by default; theme overrides go below */
|
||||
body {
|
||||
background-image:
|
||||
linear-gradient(135deg, color-mix(in oklch, var(--primary) 8%, transparent), transparent 34%),
|
||||
linear-gradient(315deg, color-mix(in oklch, var(--accent) 10%, transparent), transparent 32%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* Disable gradient wash on standout themes — they're expressive enough */
|
||||
[data-theme="terminal"] body,
|
||||
[data-theme="blueprint"] body {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
/* Blueprint gets grid lines instead */
|
||||
[data-theme="blueprint"] body {
|
||||
background-image:
|
||||
linear-gradient(to right, color-mix(in oklch, var(--foreground) 5%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, color-mix(in oklch, var(--foreground) 5%, transparent) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
/* Terminal gets scanlines */
|
||||
[data-theme="terminal"] body {
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
color-mix(in oklch, var(--primary) 4%, transparent) 2px,
|
||||
color-mix(in oklch, var(--primary) 4%, transparent) 3px
|
||||
);
|
||||
}
|
||||