Files
CapaKraken/apps/web/src/components/layout/PreferencesModal.tsx
T
Hartmut f1f1be21c7 feat: Sprint 3 — delight, polish, and responsive sidebar
Celebration micro-interactions:
- SuccessToast: auto-dismissing pill toast (success/info/warning variants)
- ConfettiBurst: pure CSS 20-particle confetti on project creation
- Project wizard: confetti + toast on successful creation
- Vacation approval/rejection: contextual toasts
- Allocation status change: success toast
- Button: active:scale-[0.97] press feedback on all variants

Collapsible sidebar + responsive:
- Desktop: toggle collapse (72px icons-only mode) with localStorage persistence
- NavTooltip: hover labels on collapsed icons
- Mobile: hamburger menu + slide-in overlay with backdrop
- Auto-close sidebar on mobile navigation
- Scroll-to-top on route change (smooth behavior)

Hover polish + accessibility:
- Table rows: animated left-border accent + hover-lift
- Stat cards + widgets: hover elevation + border glow
- Timeline blocks: scale(1.02) + shadow-md on hover
- Smooth scroll globally with prefers-reduced-motion fallback
- Filter chips: framer-motion scale+fade enter/exit
- Dropdowns: scaleY origin-top reveal animation
- Preferences modal: scale+fade entrance
- Link underline: animated ::after width expansion on hover

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-19 01:02:51 +01:00

320 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { motion } from "framer-motion";
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, setShowDemandProjects, setBlinkOverbookedDays } = 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(); }}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
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>
{/* Overbooked blink */}
<label className="flex items-start gap-3 cursor-pointer mb-3">
<div className="relative mt-0.5 flex-shrink-0">
<input
type="checkbox"
checked={appPrefs.blinkOverbookedDays}
onChange={(e) => setBlinkOverbookedDays(e.target.checked)}
className="sr-only peer"
/>
<div className={clsx(
"w-9 h-5 rounded-full transition-colors",
appPrefs.blinkOverbookedDays ? "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.blinkOverbookedDays ? "translate-x-4" : "translate-x-0",
)} />
</div>
<div>
<span className="text-sm text-gray-800 dark:text-gray-200 font-medium leading-tight block">
Blink overbooked days
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
Highlight days where a resource exceeds 8h with a pulsing animation.
</span>
</div>
</label>
<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 &amp; 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>
<label className="flex items-start gap-3 cursor-pointer mt-3">
<div className="relative mt-0.5 flex-shrink-0">
<input
type="checkbox"
checked={appPrefs.showDemandProjects}
onChange={(e) => setShowDemandProjects(e.target.checked)}
className="sr-only peer"
/>
<div className={clsx(
"w-9 h-5 rounded-full transition-colors",
appPrefs.showDemandProjects ? "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.showDemandProjects ? "translate-x-4" : "translate-x-0",
)} />
</div>
<div>
<span className="text-sm text-gray-800 dark:text-gray-200 font-medium leading-tight block">
Include demand projects on load
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
Show open staffing demands (dashed bars) when loading pages.
</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>
</motion.div>
</div>
);
}