mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(settings): color theme picker (5 themes)
Adds a new "Appearance" tab in Settings with 5 dark color themes (Violet, Emerald, Cobalt, Amber, Rose). Pick a theme → applied immediately and persisted to localStorage. Boot script in the root layout replays the pick before first paint so there's no flash-of-default on subsequent loads. Implementation, ordered the way it's read: 1. `globals.css` — 5 `html[data-theme="..."]` blocks extracted verbatim from the design handoff at project/Color Themes.html. `:root` mirrors Violet so SSR / first-paint renders correctly even before the boot script runs. Three new tokens (`--primary-hover`, `--primary-soft`, `--primary-soft-2`) added to all themes — used by tinted-primary surfaces. 2. `src/lib/themes.ts` — single source of truth for the catalog (id, name, tagline, swatch). Adding a theme = append a CSS block + append an entry here. 3. `src/hooks/use-theme.tsx` — ThemeProvider client component. Reads what the boot script already applied, syncs across tabs via the `storage` event, persists to localStorage. Falls back to a no-op setter when used outside the provider. 4. `src/app/layout.tsx` — inline `beforeInteractive` script that reads localStorage and sets `documentElement.dataset.theme` before React hydrates. Knowledge of valid theme IDs comes from the THEME_IDS constant so adding a theme doesn't silently break the boot path. Wraps the tree in ThemeProvider. 5. `src/components/settings/appearance-panel.tsx` — picker UI. 5 cards, swatch + name + tagline + a 4-tone preview strip. Active card carries a primary-tinted ring + "Active" chip. 6. `src/app/(dashboard)/settings/page.tsx` — new "appearance" tab alongside profile/whatsapp/templates/tags. 7. Key-surface tokenization — replaced hard-coded `violet-*` with `primary` tokens on the high-visibility surfaces so the theme switch feels real: sidebar logo + active nav + unread dot + avatar fallback, header avatar, dashboard-shell loading spinner, Flows header icon + entry-node picker icon + entry badge + flash ring, Flows list page (template card hover, FlowCard icon), Flows editor + runs "back" links, Settings tabs active state. NOT touched (intentional follow-ups): - Per-node-type colors in flow-builder.tsx NODE_META — those are a deliberate per-type rainbow, not a brand accent. - The inbox/contacts/pipelines surfaces — many `violet-*` usages there. Sweep is a contained follow-up; the user explicitly scoped this PR to "tokens + tab + key surfaces". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
39b03f1299
commit
54a969c56d
@ -29,7 +29,7 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-slate-950">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-violet-500 border-t-transparent" />
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-slate-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -88,7 +88,7 @@ export default function FlowEditorPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/flows")}
|
||||
className="text-sm text-violet-400 hover:text-violet-300"
|
||||
className="text-sm text-primary hover:opacity-80"
|
||||
>
|
||||
← Back to flows
|
||||
</button>
|
||||
|
||||
@ -173,7 +173,7 @@ export default function FlowRunsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/flows")}
|
||||
className="text-sm text-violet-400 hover:text-violet-300"
|
||||
className="text-sm text-primary hover:opacity-80"
|
||||
>
|
||||
← Back to flows
|
||||
</button>
|
||||
|
||||
@ -271,9 +271,9 @@ export default function FlowsPage() {
|
||||
type="button"
|
||||
onClick={() => handleUseTemplate(t.slug)}
|
||||
disabled={creating}
|
||||
className="flex flex-col gap-2.5 rounded-lg border border-slate-800 bg-slate-950 p-4 text-left transition-colors hover:border-violet-500/40 hover:bg-slate-800 disabled:opacity-50"
|
||||
className="flex flex-col gap-2.5 rounded-lg border border-slate-800 bg-slate-950 p-4 text-left transition-colors hover:border-primary/40 hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
<Icon className="h-5 w-5 text-violet-400" />
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{t.name}
|
||||
</span>
|
||||
@ -366,7 +366,7 @@ function FlowCard({
|
||||
<div className="flex flex-col rounded-lg border border-slate-800 bg-slate-900 p-4 transition-colors hover:border-slate-700">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Workflow className="h-4 w-4 shrink-0 text-violet-400" />
|
||||
<Workflow className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="truncate text-sm font-semibold text-white">
|
||||
{flow.name}
|
||||
</h3>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Settings, MessageSquare, Tag, User } from 'lucide-react';
|
||||
import { Settings, MessageSquare, Tag, User, Palette } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
||||
import { TemplateManager } from '@/components/settings/template-manager';
|
||||
@ -9,8 +9,15 @@ import { TagManager } from '@/components/settings/tag-manager';
|
||||
import { ProfileForm } from '@/components/settings/profile-form';
|
||||
import { PasswordForm } from '@/components/settings/password-form';
|
||||
import { SessionsCard } from '@/components/settings/sessions-card';
|
||||
import { AppearancePanel } from '@/components/settings/appearance-panel';
|
||||
|
||||
const TAB_VALUES = ['profile', 'whatsapp', 'templates', 'tags'] as const;
|
||||
const TAB_VALUES = [
|
||||
'profile',
|
||||
'whatsapp',
|
||||
'templates',
|
||||
'tags',
|
||||
'appearance',
|
||||
] as const;
|
||||
type TabValue = (typeof TAB_VALUES)[number];
|
||||
|
||||
function isTabValue(v: string | null): v is TabValue {
|
||||
@ -48,32 +55,39 @@ export default function SettingsPage() {
|
||||
<TabsList className="bg-slate-900 border border-slate-700">
|
||||
<TabsTrigger
|
||||
value="profile"
|
||||
className="data-active:bg-slate-800 data-active:text-violet-400 text-slate-400"
|
||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||
>
|
||||
<User className="size-4" />
|
||||
Profile
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="whatsapp"
|
||||
className="data-active:bg-slate-800 data-active:text-violet-400 text-slate-400"
|
||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
WhatsApp Config
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="templates"
|
||||
className="data-active:bg-slate-800 data-active:text-violet-400 text-slate-400"
|
||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
Templates
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="tags"
|
||||
className="data-active:bg-slate-800 data-active:text-violet-400 text-slate-400"
|
||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||
>
|
||||
<Tag className="size-4" />
|
||||
Tags
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="appearance"
|
||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||
>
|
||||
<Palette className="size-4" />
|
||||
Appearance
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-6">
|
||||
@ -93,6 +107,10 @@ export default function SettingsPage() {
|
||||
<TabsContent value="tags">
|
||||
<TagManager />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="appearance">
|
||||
<AppearancePanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -48,7 +48,29 @@
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
/* ============================================================
|
||||
* COLOR THEMES
|
||||
*
|
||||
* 5 dark themes sharing structure, swapping only the primary
|
||||
* accent (and a tiny hue rotation on the slate base). Switched
|
||||
* at runtime by setting `document.documentElement.dataset.theme`
|
||||
* — see ThemeProvider + the inline boot script in layout.tsx.
|
||||
*
|
||||
* `:root` mirrors Violet so default visitors render correctly
|
||||
* before JS runs. `[data-theme="violet"]` is included for the
|
||||
* explicit-select path (settings UI sets it after a pick).
|
||||
*
|
||||
* The three `--primary-*` extras (hover / soft / soft-2) are
|
||||
* new — used for hovered primary buttons and tinted-primary
|
||||
* surfaces (sidebar active pill, validation badges). Components
|
||||
* that don't use them remain backward-compatible.
|
||||
*
|
||||
* Token shapes come straight from the design handoff at
|
||||
* project/Color Themes.html — change one, change the other.
|
||||
* ============================================================ */
|
||||
|
||||
:root,
|
||||
html[data-theme="violet"] {
|
||||
--background: oklch(0.13 0.01 260);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.18 0.01 260);
|
||||
@ -57,6 +79,9 @@
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.526 0.247 293);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--primary-hover: oklch(0.6 0.22 293);
|
||||
--primary-soft: oklch(0.526 0.247 293 / 0.12);
|
||||
--primary-soft-2: oklch(0.526 0.247 293 / 0.2);
|
||||
--secondary: oklch(0.22 0.01 260);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.22 0.01 260);
|
||||
@ -83,6 +108,154 @@
|
||||
--sidebar-ring: oklch(0.526 0.247 293);
|
||||
}
|
||||
|
||||
html[data-theme="emerald"] {
|
||||
--background: oklch(0.135 0.01 200);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.185 0.01 200);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.185 0.01 200);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.62 0.16 162);
|
||||
--primary-foreground: oklch(0.16 0.02 162);
|
||||
--primary-hover: oklch(0.68 0.15 162);
|
||||
--primary-soft: oklch(0.62 0.16 162 / 0.12);
|
||||
--primary-soft-2: oklch(0.62 0.16 162 / 0.22);
|
||||
--secondary: oklch(0.225 0.01 200);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.225 0.01 200);
|
||||
--muted-foreground: oklch(0.65 0.01 200);
|
||||
--accent: oklch(0.225 0.01 200);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.285 0.01 200);
|
||||
--input: oklch(0.285 0.01 200);
|
||||
--ring: oklch(0.62 0.16 162);
|
||||
--chart-1: oklch(0.62 0.16 162);
|
||||
--chart-2: oklch(0.7 0.14 195);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.165 0.01 200);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.62 0.16 162);
|
||||
--sidebar-primary-foreground: oklch(0.16 0.02 162);
|
||||
--sidebar-accent: oklch(0.225 0.01 200);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.285 0.01 200);
|
||||
--sidebar-ring: oklch(0.62 0.16 162);
|
||||
}
|
||||
|
||||
html[data-theme="cobalt"] {
|
||||
--background: oklch(0.13 0.015 252);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.18 0.015 252);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.18 0.015 252);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.585 0.2 254);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--primary-hover: oklch(0.66 0.18 254);
|
||||
--primary-soft: oklch(0.585 0.2 254 / 0.12);
|
||||
--primary-soft-2: oklch(0.585 0.2 254 / 0.22);
|
||||
--secondary: oklch(0.22 0.015 252);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.22 0.015 252);
|
||||
--muted-foreground: oklch(0.65 0.012 252);
|
||||
--accent: oklch(0.22 0.015 252);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.28 0.015 252);
|
||||
--input: oklch(0.28 0.015 252);
|
||||
--ring: oklch(0.585 0.2 254);
|
||||
--chart-1: oklch(0.585 0.2 254);
|
||||
--chart-2: oklch(0.7 0.15 220);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.16 0.015 252);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.585 0.2 254);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.22 0.015 252);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.28 0.015 252);
|
||||
--sidebar-ring: oklch(0.585 0.2 254);
|
||||
}
|
||||
|
||||
html[data-theme="amber"] {
|
||||
--background: oklch(0.135 0.012 60);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.185 0.012 60);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.185 0.012 60);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.745 0.16 65);
|
||||
--primary-foreground: oklch(0.18 0.03 65);
|
||||
--primary-hover: oklch(0.8 0.15 65);
|
||||
--primary-soft: oklch(0.745 0.16 65 / 0.12);
|
||||
--primary-soft-2: oklch(0.745 0.16 65 / 0.22);
|
||||
--secondary: oklch(0.225 0.012 60);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.225 0.012 60);
|
||||
--muted-foreground: oklch(0.66 0.01 60);
|
||||
--accent: oklch(0.225 0.012 60);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.285 0.012 60);
|
||||
--input: oklch(0.285 0.012 60);
|
||||
--ring: oklch(0.745 0.16 65);
|
||||
--chart-1: oklch(0.745 0.16 65);
|
||||
--chart-2: oklch(0.7 0.15 35);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.165 0.012 60);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.745 0.16 65);
|
||||
--sidebar-primary-foreground: oklch(0.18 0.03 65);
|
||||
--sidebar-accent: oklch(0.225 0.012 60);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.285 0.012 60);
|
||||
--sidebar-ring: oklch(0.745 0.16 65);
|
||||
}
|
||||
|
||||
html[data-theme="rose"] {
|
||||
--background: oklch(0.13 0.012 15);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.18 0.012 15);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.18 0.012 15);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.645 0.22 16);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--primary-hover: oklch(0.71 0.2 16);
|
||||
--primary-soft: oklch(0.645 0.22 16 / 0.12);
|
||||
--primary-soft-2: oklch(0.645 0.22 16 / 0.22);
|
||||
--secondary: oklch(0.22 0.012 15);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.22 0.012 15);
|
||||
--muted-foreground: oklch(0.65 0.01 15);
|
||||
--accent: oklch(0.22 0.012 15);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.28 0.012 15);
|
||||
--input: oklch(0.28 0.012 15);
|
||||
--ring: oklch(0.645 0.22 16);
|
||||
--chart-1: oklch(0.645 0.22 16);
|
||||
--chart-2: oklch(0.7 0.18 340);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.16 0.012 15);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.645 0.22 16);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.22 0.012 15);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.28 0.012 15);
|
||||
--sidebar-ring: oklch(0.645 0.22 16);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/hooks/use-theme";
|
||||
import { DEFAULT_THEME, STORAGE_KEY, THEME_IDS } from "@/lib/themes";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-sans",
|
||||
@ -33,26 +36,63 @@ export const viewport: Viewport = {
|
||||
colorScheme: "dark",
|
||||
};
|
||||
|
||||
// Inline boot script — runs before React hydrates so the user's
|
||||
// chosen theme is on the <html> element before first paint. Without
|
||||
// this every page load flashes the default Violet for a frame before
|
||||
// the React tree mounts and applies the picked theme.
|
||||
//
|
||||
// Kept dependency-free (no imports, no JSX) — must be a string the
|
||||
// browser can run as a single <script>. Knowledge of valid theme IDs
|
||||
// is sourced from the THEME_IDS constant so adding a theme doesn't
|
||||
// silently break the boot path.
|
||||
const THEME_BOOT_SCRIPT = `
|
||||
(function(){
|
||||
try {
|
||||
var STORAGE_KEY = ${JSON.stringify(STORAGE_KEY)};
|
||||
var DEFAULT = ${JSON.stringify(DEFAULT_THEME)};
|
||||
var ALLOWED = ${JSON.stringify(THEME_IDS)};
|
||||
var saved = localStorage.getItem(STORAGE_KEY);
|
||||
var theme = ALLOWED.indexOf(saved) !== -1 ? saved : DEFAULT;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
} catch (_e) {
|
||||
document.documentElement.dataset.theme = ${JSON.stringify(DEFAULT_THEME)};
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} h-full antialiased`}>
|
||||
<body className="min-h-full bg-slate-950 text-white font-sans">
|
||||
{children}
|
||||
<Toaster
|
||||
theme="dark"
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "rgb(30 41 59)",
|
||||
border: "1px solid rgb(51 65 85)",
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
<html
|
||||
lang="en"
|
||||
data-theme={DEFAULT_THEME}
|
||||
className={`${inter.variable} h-full antialiased`}
|
||||
>
|
||||
<head>
|
||||
<Script
|
||||
id="theme-boot"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }}
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-full bg-background text-foreground font-sans">
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<Toaster
|
||||
theme="dark"
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "rgb(30 41 59)",
|
||||
border: "1px solid rgb(51 65 85)",
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -712,7 +712,7 @@ function Header({
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Workflow className="h-5 w-5 shrink-0 text-violet-400" />
|
||||
<Workflow className="h-5 w-5 shrink-0 text-primary" />
|
||||
<Input
|
||||
value={state.name}
|
||||
onChange={(e) =>
|
||||
@ -920,7 +920,7 @@ function EntryPicker({
|
||||
if (state.nodes.length === 0) return null;
|
||||
return (
|
||||
<section className="flex items-center gap-3 rounded-lg border border-slate-800 bg-slate-900 p-3">
|
||||
<CornerDownRight className="h-4 w-4 shrink-0 text-violet-400" />
|
||||
<CornerDownRight className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="text-xs text-slate-400">Entry node:</span>
|
||||
<NodeKeySelect
|
||||
value={state.entry_node_id}
|
||||
@ -977,10 +977,10 @@ function NodeCard({
|
||||
hasError
|
||||
? "border-red-500/40"
|
||||
: isEntry
|
||||
? "border-violet-500/40"
|
||||
? "border-primary/50"
|
||||
: "border-slate-800",
|
||||
isFlashed &&
|
||||
"ring-2 ring-violet-400 ring-offset-2 ring-offset-slate-950",
|
||||
"ring-2 ring-primary ring-offset-2 ring-offset-slate-950",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
@ -1000,7 +1000,7 @@ function NodeCard({
|
||||
{isEntry && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-violet-500/40 bg-violet-500/10 text-[10px] text-violet-300"
|
||||
className="border-primary/40 bg-primary/10 text-[10px] text-primary"
|
||||
>
|
||||
Entry
|
||||
</Badge>
|
||||
|
||||
@ -80,7 +80,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
|
||||
alt={profile.full_name ?? "Avatar"}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-violet-500/10 text-sm font-medium text-violet-500">
|
||||
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@ -129,8 +129,8 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
close button is hidden since the sidebar is always-visible. */}
|
||||
<div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-slate-800 px-4">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500">
|
||||
<MessageSquare className="h-4 w-4 text-white" />
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
CRM Template for WhatsApp
|
||||
@ -172,7 +172,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
// Taller on mobile so fingers can hit the row reliably (≥44px).
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
|
||||
isActive
|
||||
? "bg-violet-500/10 text-violet-500"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-white",
|
||||
)}
|
||||
>
|
||||
@ -183,8 +183,8 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`}
|
||||
className="relative flex h-2 w-2"
|
||||
>
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-violet-500" />
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
@ -205,7 +205,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
|
||||
isActive
|
||||
? "bg-violet-500/10 text-violet-500"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-white",
|
||||
)}
|
||||
>
|
||||
@ -229,7 +229,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
alt={profile.full_name ?? "Avatar"}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-violet-500/10 text-sm font-medium text-violet-500">
|
||||
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
|
||||
{profile?.full_name?.charAt(0)?.toUpperCase() ??
|
||||
profile?.email?.charAt(0)?.toUpperCase() ??
|
||||
"U"}
|
||||
|
||||
113
src/components/settings/appearance-panel.tsx
Normal file
113
src/components/settings/appearance-panel.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { useTheme } from "@/hooks/use-theme";
|
||||
import { THEMES, type ThemeId } from "@/lib/themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Appearance panel — color-theme picker.
|
||||
*
|
||||
* Click a card → applies + persists immediately. No save button:
|
||||
* the whole change is a single CSS-variable swap on <html>, there's
|
||||
* nothing to roll back. The active card carries a check chip + a
|
||||
* primary-tinted border so the current pick is obvious.
|
||||
*
|
||||
* Persistence: localStorage only (device-scoped). The boot script in
|
||||
* layout.tsx replays the choice before first paint on subsequent
|
||||
* loads.
|
||||
*/
|
||||
export function AppearancePanel() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Color theme</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Pick the accent color used across the app. All themes stay
|
||||
dark — only the primary color (buttons, active nav, badges)
|
||||
changes. Saved to this device.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{THEMES.map((t) => (
|
||||
<ThemeCard
|
||||
key={t.id}
|
||||
id={t.id}
|
||||
name={t.name}
|
||||
tagline={t.tagline}
|
||||
swatch={t.swatch}
|
||||
isActive={t.id === theme}
|
||||
onPick={() => setTheme(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeCard({
|
||||
id,
|
||||
name,
|
||||
tagline,
|
||||
swatch,
|
||||
isActive,
|
||||
onPick,
|
||||
}: {
|
||||
id: ThemeId;
|
||||
name: string;
|
||||
tagline: string;
|
||||
swatch: string;
|
||||
isActive: boolean;
|
||||
onPick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
aria-pressed={isActive}
|
||||
aria-label={`Use ${name} theme`}
|
||||
className={cn(
|
||||
"flex flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
|
||||
isActive
|
||||
? "border-primary/60 ring-2 ring-primary/40"
|
||||
: "border-slate-800 hover:border-slate-700 hover:bg-slate-800/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
style={{
|
||||
background: swatch,
|
||||
boxShadow: "inset 0 0 0 1px oklch(1 0 0 / 0.15)",
|
||||
}}
|
||||
/>
|
||||
{isActive && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{name}</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-slate-400">
|
||||
{tagline}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 flex h-2 overflow-hidden rounded-full"
|
||||
aria-hidden
|
||||
>
|
||||
<span className="flex-1" style={{ background: swatch }} />
|
||||
<span className="w-3 bg-slate-700" />
|
||||
<span className="w-3 bg-slate-800" />
|
||||
<span className="w-3 bg-slate-900" />
|
||||
</div>
|
||||
<span className="sr-only">Theme id: {id}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
106
src/hooks/use-theme.tsx
Normal file
106
src/hooks/use-theme.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_THEME,
|
||||
STORAGE_KEY,
|
||||
isThemeId,
|
||||
type ThemeId,
|
||||
} from "@/lib/themes";
|
||||
|
||||
/**
|
||||
* ThemeProvider — wraps the whole app, owns the active theme state.
|
||||
*
|
||||
* The boot script in `src/app/layout.tsx` has already applied
|
||||
* `document.documentElement.dataset.theme` before React hydrates, so
|
||||
* by the time this Provider mounts the page is already painted in
|
||||
* the right colors. We just have to read what's there and keep it
|
||||
* in sync going forward.
|
||||
*
|
||||
* Persistence is localStorage only (device-scoped). A future
|
||||
* follow-up could mirror to `profiles.preferences` for cross-device
|
||||
* sync, but a per-device choice is also defensible — your phone may
|
||||
* deserve a different theme than your laptop.
|
||||
*/
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: ThemeId;
|
||||
setTheme: (next: ThemeId) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function readInitialTheme(): ThemeId {
|
||||
if (typeof window === "undefined") return DEFAULT_THEME;
|
||||
// Whatever the boot script applied is the truth. Fall back to
|
||||
// localStorage / default if for some reason the attribute is missing
|
||||
// (e.g. someone bypassed the boot script in a custom layout).
|
||||
const fromAttr = document.documentElement.dataset.theme;
|
||||
if (isThemeId(fromAttr)) return fromAttr;
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (isThemeId(stored)) return stored;
|
||||
} catch {
|
||||
// localStorage can throw in private-browsing / sandboxed contexts.
|
||||
}
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemeId>(readInitialTheme);
|
||||
|
||||
const setTheme = useCallback((next: ThemeId) => {
|
||||
setThemeState(next);
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dataset.theme = next;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Same private-browsing edge case as above; the in-memory state
|
||||
// still updates so the current tab works for the session.
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync from other tabs — if you change your theme in tab A, tab B
|
||||
// catches up without a refresh.
|
||||
useEffect(() => {
|
||||
function onStorage(e: StorageEvent) {
|
||||
if (e.key !== STORAGE_KEY) return;
|
||||
if (isThemeId(e.newValue) && e.newValue !== theme) {
|
||||
setThemeState(e.newValue);
|
||||
document.documentElement.dataset.theme = e.newValue;
|
||||
}
|
||||
}
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) {
|
||||
// Fallback for components rendered outside the provider — return a
|
||||
// no-op setter so callers don't crash. The boot script still
|
||||
// applied the right CSS attribute, so visually the page is fine.
|
||||
return {
|
||||
theme: DEFAULT_THEME,
|
||||
setTheme: () => {},
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
81
src/lib/themes.ts
Normal file
81
src/lib/themes.ts
Normal file
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Single source of truth for the color-theme catalog.
|
||||
*
|
||||
* The CSS variables themselves live in `src/app/globals.css` under
|
||||
* `html[data-theme="..."]` blocks — that file is the one we paste
|
||||
* theme tokens into. This module only carries the metadata the UI
|
||||
* (settings picker, no-flash boot script) needs.
|
||||
*
|
||||
* Adding a new theme is a two-step change:
|
||||
* 1. Append the new `html[data-theme="<id>"]` block in globals.css
|
||||
* with every token from an existing theme (use violet as the
|
||||
* shape reference).
|
||||
* 2. Add an entry below. The order here drives the picker grid.
|
||||
*/
|
||||
|
||||
export const THEME_IDS = [
|
||||
"violet",
|
||||
"emerald",
|
||||
"cobalt",
|
||||
"amber",
|
||||
"rose",
|
||||
] as const;
|
||||
|
||||
export type ThemeId = (typeof THEME_IDS)[number];
|
||||
|
||||
export const DEFAULT_THEME: ThemeId = "violet";
|
||||
|
||||
export const STORAGE_KEY = "wacrm.theme";
|
||||
|
||||
export interface ThemeMeta {
|
||||
id: ThemeId;
|
||||
name: string;
|
||||
tagline: string;
|
||||
/**
|
||||
* Static swatch color for the picker chip. Hard-coded so the boot
|
||||
* script / picker cards don't need a getComputedStyle round trip
|
||||
* before the page settles. Must mirror `--primary` of the same
|
||||
* theme in globals.css.
|
||||
*/
|
||||
swatch: string;
|
||||
}
|
||||
|
||||
export const THEMES: ReadonlyArray<ThemeMeta> = [
|
||||
{
|
||||
id: "violet",
|
||||
name: "Violet",
|
||||
tagline: "The default — confident, slightly playful.",
|
||||
swatch: "oklch(0.526 0.247 293)",
|
||||
},
|
||||
{
|
||||
id: "emerald",
|
||||
name: "Emerald",
|
||||
tagline: "Growth-coded, nods at messaging without copying WhatsApp green.",
|
||||
swatch: "oklch(0.62 0.16 162)",
|
||||
},
|
||||
{
|
||||
id: "cobalt",
|
||||
name: "Cobalt",
|
||||
tagline: "Clean B2B-SaaS blue — calm and product-y.",
|
||||
swatch: "oklch(0.585 0.2 254)",
|
||||
},
|
||||
{
|
||||
id: "amber",
|
||||
name: "Amber",
|
||||
tagline: "Warm and friendly — feels good for SMB teams.",
|
||||
swatch: "oklch(0.745 0.16 65)",
|
||||
},
|
||||
{
|
||||
id: "rose",
|
||||
name: "Rose",
|
||||
tagline: "Bold and modern — D2C, creator-economy, lifestyle.",
|
||||
swatch: "oklch(0.645 0.22 16)",
|
||||
},
|
||||
];
|
||||
|
||||
export function isThemeId(value: unknown): value is ThemeId {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(THEME_IDS as ReadonlyArray<string>).includes(value)
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user