chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import { Suspense, useState } from "react";
|
||||
import { PreferencesModal } from "./PreferencesModal.js";
|
||||
import { ThemeProvider } from "./ThemeProvider.js";
|
||||
import { NotificationBell } from "../notifications/NotificationBell.js";
|
||||
import { NavProgressBar } from "~/components/ui/NavProgressBar.js";
|
||||
|
||||
const allNavItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: "📊", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
{ href: "/resources", label: "Resources", icon: "👥", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/projects", label: "Projects", icon: "📋", roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
|
||||
{ href: "/estimates", label: "Estimates", icon: "🧮", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
{ href: "/allocations", label: "Allocations", icon: "📅", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/timeline", label: "Timeline", icon: "🗓️", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
{ href: "/staffing", label: "Staffing", icon: "🎯", roles: ["ADMIN", "MANAGER"] },
|
||||
{ href: "/vacations", label: "Vacations", icon: "🏖️", roles: ["ADMIN", "MANAGER"] },
|
||||
{ href: "/vacations/my", label: "My Vacations", icon: "🌴", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
{ href: "/roles", label: "Roles", icon: "🏷️", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/analytics/skills", label: "Skills Analytics", icon: "📈", roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
|
||||
{ href: "/reports/chargeability", label: "Chargeability", icon: "📊", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
];
|
||||
|
||||
const adminNavItems = [
|
||||
{ href: "/admin/blueprints", label: "Blueprints", icon: "🏗️" },
|
||||
{ href: "/admin/countries", label: "Countries", icon: "🌍" },
|
||||
{ href: "/admin/org-units", label: "Org Units", icon: "🏢" },
|
||||
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: "📊" },
|
||||
{ href: "/admin/clients", label: "Clients", icon: "🏦" },
|
||||
{ href: "/admin/rate-cards", label: "Rate Cards", icon: "💲" },
|
||||
{ href: "/admin/effort-rules", label: "Effort Rules", icon: "📐" },
|
||||
{ href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: "📈" },
|
||||
{ href: "/admin/management-levels", label: "Mgmt Levels", icon: "📶" },
|
||||
{ href: "/admin/users", label: "Users", icon: "👤" },
|
||||
{ href: "/admin/settings", label: "Settings", icon: "⚙️" },
|
||||
{ href: "/admin/skill-import", label: "Skill Import", icon: "📥" },
|
||||
];
|
||||
|
||||
const managerNavItems = [
|
||||
{ href: "/admin/vacations", label: "Vacation Mgmt", icon: "🏖️" },
|
||||
];
|
||||
|
||||
function Sidebar({ userRole }: { userRole: string }) {
|
||||
const pathname = usePathname();
|
||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||
|
||||
const visibleNavItems = allNavItems.filter((item) => item.roles.includes(userRole));
|
||||
const showAdmin = userRole === "ADMIN";
|
||||
const showManagerSection = userRole === "ADMIN" || userRole === "MANAGER";
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0">
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-50">
|
||||
Pl<span className="text-brand-600">anarchy</span>
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">Resource Planning</p>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<div className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||
{visibleNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as Route}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
pathname.startsWith(item.href)
|
||||
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
|
||||
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{showManagerSection && (
|
||||
<>
|
||||
<div className="pt-3 pb-1">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<span className="px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{showAdmin ? "Admin" : "Management"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{showAdmin && adminNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as Route}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
pathname.startsWith(item.href)
|
||||
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
|
||||
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{managerNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as Route}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
pathname.startsWith(item.href)
|
||||
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
|
||||
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom actions */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-800 space-y-1">
|
||||
<div className="flex items-center gap-2 px-3 py-1">
|
||||
<NotificationBell />
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">Notifications</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPrefsOpen(true)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Preferences
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut({ callbackUrl: "/auth/signin" })}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{prefsOpen && <PreferencesModal onClose={() => setPrefsOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children, userRole = "USER" }: { children: React.ReactNode; userRole?: string }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Suspense>
|
||||
<NavProgressBar />
|
||||
</Suspense>
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<Sidebar userRole={userRole} />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "~/hooks/useTheme.js";
|
||||
import type { AccentColor, ThemeMode } from "~/hooks/useTheme.js";
|
||||
import { useAppPreferences, type HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
interface PreferencesModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ACCENT_OPTIONS: { value: AccentColor; label: string; swatch: string }[] = [
|
||||
{ value: "sky", label: "Sky", swatch: "#0284c7" },
|
||||
{ value: "indigo", label: "Indigo", swatch: "#4f46e5" },
|
||||
{ value: "violet", label: "Violet", swatch: "#7c3aed" },
|
||||
{ value: "emerald", label: "Emerald", swatch: "#059669" },
|
||||
{ value: "rose", label: "Rose", swatch: "#e11d48" },
|
||||
{ value: "amber", label: "Amber", swatch: "#d97706" },
|
||||
];
|
||||
|
||||
export function PreferencesModal({ onClose }: PreferencesModalProps) {
|
||||
const { prefs, setMode, setAccent } = useTheme();
|
||||
const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme } = useAppPreferences();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-end sm:items-center justify-center p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Preferences</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-6">
|
||||
{/* Theme mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Appearance
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(["light", "dark"] as ThemeMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setMode(mode)}
|
||||
className={clsx(
|
||||
"flex items-center justify-center gap-2 px-4 py-3 rounded-xl border-2 text-sm font-medium transition-all",
|
||||
prefs.mode === mode
|
||||
? "border-brand-600 bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
|
||||
)}
|
||||
>
|
||||
{mode === "light" ? (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
Light
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
Dark
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accent color */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Highlight Color
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{ACCENT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setAccent(opt.value)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-2.5 rounded-xl border-2 text-xs font-medium transition-all",
|
||||
prefs.accent === opt.value
|
||||
? "border-brand-600 bg-brand-50 dark:bg-gray-700"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="w-4 h-4 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor: opt.swatch,
|
||||
boxShadow: prefs.accent === opt.value ? `0 0 0 2px white, 0 0 0 4px ${opt.swatch}` : "none",
|
||||
}}
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">{opt.label}</span>
|
||||
{prefs.accent === opt.value && (
|
||||
<svg className="w-3 h-3 ml-auto text-brand-600 shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fillRule="evenodd" d="M20.707 5.293a1 1 0 010 1.414l-11 11a1 1 0 01-1.414 0l-5-5a1 1 0 011.414-1.414L9 15.586 19.293 5.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline defaults */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Timeline
|
||||
</label>
|
||||
|
||||
{/* Display mode */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Row display style</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
{ value: "strip", label: "Strips", desc: "Classic Gantt blocks" },
|
||||
{ value: "bar", label: "Bars", desc: "Daily stacked hours" },
|
||||
{ value: "heatmap", label: "Heatmap", desc: "Utilisation colours" },
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setTimelineDisplayMode(opt.value)}
|
||||
className={clsx(
|
||||
"flex flex-col items-center gap-1 px-3 py-2.5 rounded-xl border-2 text-xs font-medium transition-all",
|
||||
appPrefs.timelineDisplayMode === opt.value
|
||||
? "border-brand-600 bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
|
||||
)}
|
||||
>
|
||||
{/* Miniature icon */}
|
||||
{opt.value === "strip" ? (
|
||||
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
|
||||
<rect x="2" y="4" width="12" height="8" rx="2" fill="currentColor" opacity="0.6" />
|
||||
<rect x="18" y="4" width="12" height="8" rx="2" fill="currentColor" opacity="0.4" />
|
||||
</svg>
|
||||
) : opt.value === "bar" ? (
|
||||
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
|
||||
<rect x="2" y="8" width="5" height="8" rx="1" fill="currentColor" opacity="0.6" />
|
||||
<rect x="9" y="4" width="5" height="12" rx="1" fill="currentColor" opacity="0.5" />
|
||||
<rect x="16" y="6" width="5" height="10" rx="1" fill="currentColor" opacity="0.7" />
|
||||
<rect x="23" y="2" width="5" height="14" rx="1" fill="currentColor" opacity="0.4" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
|
||||
<rect x="0" y="0" width="8" height="16" fill="#22c55e" opacity="0.5" />
|
||||
<rect x="8" y="0" width="8" height="16" fill="#eab308" opacity="0.5" />
|
||||
<rect x="16" y="0" width="8" height="16" fill="#f97316" opacity="0.5" />
|
||||
<rect x="24" y="0" width="8" height="16" fill="#ef4444" opacity="0.6" />
|
||||
<rect x="2" y="4" width="10" height="8" rx="1" fill="white" opacity="0.7" />
|
||||
<rect x="18" y="4" width="10" height="8" rx="1" fill="white" opacity="0.5" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{opt.label}</span>
|
||||
<span className="font-normal text-[10px] opacity-70">{opt.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Heatmap color scheme */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Heatmap color scale</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{
|
||||
value: "green-red" as HeatmapColorScheme,
|
||||
label: "Green → Red",
|
||||
stops: ["#22c55e","#84cc16","#facc15","#f97316","#ef4444"],
|
||||
},
|
||||
{
|
||||
value: "blue-orange" as HeatmapColorScheme,
|
||||
label: "Blue → Orange",
|
||||
stops: ["#38bdf8","#3b82f6","#fbbf24","#f97316","#ef4444"],
|
||||
},
|
||||
{
|
||||
value: "purple-yellow" as HeatmapColorScheme,
|
||||
label: "Purple → Yellow",
|
||||
stops: ["#a78bfa","#8b5cf6","#facc15","#f59e0b","#ef4444"],
|
||||
},
|
||||
{
|
||||
value: "mono" as HeatmapColorScheme,
|
||||
label: "Monochrome",
|
||||
stops: ["#9ca3af","#6b7280","#4b5563","#374151","#111827"],
|
||||
},
|
||||
]).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setHeatmapColorScheme(opt.value)}
|
||||
className={clsx(
|
||||
"flex flex-col gap-1.5 px-2.5 py-2 rounded-xl border-2 text-xs font-medium transition-all text-left",
|
||||
appPrefs.heatmapColorScheme === opt.value
|
||||
? "border-brand-600 bg-brand-50 dark:bg-brand-900/30"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
{/* Gradient swatch */}
|
||||
<div className="flex rounded overflow-hidden w-full h-3">
|
||||
{opt.stops.map((c) => (
|
||||
<div key={c} className="flex-1" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-gray-700 dark:text-gray-300">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<div className="relative mt-0.5 flex-shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appPrefs.hideCompletedProjects}
|
||||
onChange={(e) => setHideCompletedProjects(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className={clsx(
|
||||
"w-9 h-5 rounded-full transition-colors",
|
||||
appPrefs.hideCompletedProjects ? "bg-brand-600" : "bg-gray-200 dark:bg-gray-700",
|
||||
)} />
|
||||
<div className={clsx(
|
||||
"absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform",
|
||||
appPrefs.hideCompletedProjects ? "translate-x-4" : "translate-x-0",
|
||||
)} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-800 dark:text-gray-200 font-medium leading-tight block">
|
||||
Hide completed & cancelled projects
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Can be overridden per session in the timeline filter panel.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Preview note */}
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Changes apply instantly and are saved in your browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Applies the stored theme to <html> immediately on mount (client only).
|
||||
* Must be rendered inside the layout BEFORE the page content.
|
||||
*/
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem("planarchy_theme");
|
||||
if (!raw) return;
|
||||
const prefs = JSON.parse(raw) as { mode?: string; accent?: string };
|
||||
const html = document.documentElement;
|
||||
if (prefs.mode === "dark") html.classList.add("dark");
|
||||
else html.classList.remove("dark");
|
||||
if (prefs.accent) html.setAttribute("data-accent", prefs.accent);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user