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>
This commit is contained in:
2026-03-19 01:02:51 +01:00
parent a97597093f
commit f1f1be21c7
15 changed files with 804 additions and 177 deletions
+479 -140
View File
@@ -6,7 +6,7 @@ import Link from "next/link";
import type { Route } from "next";
import { usePathname } from "next/navigation";
import { clsx } from "clsx";
import { Suspense, useMemo, useState } from "react";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { PreferencesModal } from "./PreferencesModal.js";
import { ThemeProvider } from "./ThemeProvider.js";
@@ -15,9 +15,11 @@ import { NotificationBell } from "../notifications/NotificationBell.js";
import { ChatPanel } from "../assistant/ChatPanel.js";
import { NavProgressBar } from "~/components/ui/NavProgressBar.js";
const SIDEBAR_COLLAPSED_KEY = "planarchy_sidebar_collapsed";
function IconFrame({ children }: { children: ReactNode }) {
return (
<span className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/60 bg-white/80 text-slate-600 shadow-sm transition-shadow duration-200 hover:shadow-[0_0_12px_rgba(var(--accent-500),0.15)] dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-300">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-white/60 bg-white/80 text-slate-600 shadow-sm transition-shadow duration-200 hover:shadow-[0_0_12px_rgba(var(--accent-500),0.15)] dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-300">
{children}
</span>
);
@@ -69,6 +71,35 @@ function AdminIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 8a4 4 0 100 8 4 4 0 000-8zm8 4l-2.1.7a7.9 7.9 0 01-.6 1.5l1 2-2.1 2.1-2-1a7.9 7.9 0 01-1.5.6L12 20l-1.7-2.1a7.9 7.9 0 01-1.5-.6l-2 1-2.1-2.1 1-2a7.9 7.9 0 01-.6-1.5L4 12l2.1-1.7a7.9 7.9 0 01.6-1.5l-1-2 2.1-2.1 2 1a7.9 7.9 0 011.5-.6L12 4l1.7 2.1a7.9 7.9 0 011.5.6l2-1 2.1 2.1-1 2a7.9 7.9 0 01.6 1.5L20 12z" /></svg>;
}
function CollapseIcon({ collapsed }: { collapsed: boolean }) {
return (
<svg
className={clsx("h-4 w-4 transition-transform duration-200", collapsed && "rotate-180")}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
);
}
function HamburgerIcon() {
return (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
}
function CloseIcon() {
return (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
type NavItem = { href: string; label: string; icon: ReactNode; roles: string[] };
type NavSection = { label: string; collapsed?: boolean; items: NavItem[] };
@@ -150,8 +181,6 @@ const adminNavEntries: AdminEntry[] = [
/**
* Collect every href registered in the sidebar so that the active-check
* can determine whether a more-specific sibling matches the current path.
* Example: when pathname is `/vacations/my`, the item `/vacations` must NOT
* highlight because `/vacations/my` is a more-specific registered route.
*/
const ALL_NAV_HREFS: string[] = (() => {
const hrefs: string[] = [];
@@ -171,19 +200,57 @@ const ALL_NAV_HREFS: string[] = (() => {
function isNavItemActive(pathname: string, href: string): boolean {
if (pathname === href) return true;
if (!pathname.startsWith(href + "/")) return false;
// pathname starts with `href/...` — but a more-specific registered route may match.
// If another nav href is a longer prefix match, this shorter one should NOT be active.
const hasMoreSpecificSibling = ALL_NAV_HREFS.some(
(other) => other !== href && other.length > href.length && pathname.startsWith(other),
);
return !hasMoreSpecificSibling;
}
function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () => void }) {
/* ------------------------------------------------------------------ */
/* Tooltip wrapper — shows label on hover when sidebar is collapsed */
/* ------------------------------------------------------------------ */
function NavTooltip({
label,
show,
children,
}: {
label: string;
show: boolean;
children: ReactNode;
}) {
if (!show) return <>{children}</>;
return (
<div className="group/tip relative">
{children}
<div className="pointer-events-none absolute left-full top-1/2 z-50 ml-2 -translate-y-1/2 whitespace-nowrap rounded-lg bg-gray-900 px-2.5 py-1.5 text-xs font-medium text-white opacity-0 shadow-lg transition-opacity group-hover/tip:opacity-100 dark:bg-slate-700">
{label}
<div className="absolute right-full top-1/2 -translate-y-1/2 border-4 border-transparent border-r-gray-900 dark:border-r-slate-700" />
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Sidebar component */
/* ------------------------------------------------------------------ */
function SidebarContent({
userRole,
onChatOpen,
sidebarCollapsed,
onToggleCollapse,
onNavClick,
}: {
userRole: string;
onChatOpen: () => void;
sidebarCollapsed: boolean;
onToggleCollapse: () => void;
onNavClick?: () => void;
}) {
const pathname = usePathname();
const [prefsOpen, setPrefsOpen] = useState(false);
// Memoize active href set — avoids O(n²) on every render
const activeHrefSet = useMemo(() => {
const set = new Set<string>();
for (const href of ALL_NAV_HREFS) {
@@ -201,7 +268,6 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
const showAdmin = userRole === "ADMIN";
const showManagerSection = userRole === "ADMIN" || userRole === "MANAGER";
// Sections and sub-groups auto-expand when the current route matches an item inside them
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
for (const section of visibleSections) {
@@ -223,32 +289,45 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
setCollapsedSections((prev) => ({ ...prev, [label]: !prev[label] }));
};
const handleLinkClick = () => {
onNavClick?.();
};
return (
<>
<nav className="w-72 shrink-0 border-r border-gray-200/60 bg-white/60 shadow-[1px_0_12px_rgba(0,0,0,0.03)] backdrop-blur-2xl backdrop-saturate-150 dark:border-slate-800 dark:bg-slate-950/75 dark:shadow-none flex flex-col">
{/* Logo */}
<div className="border-b border-gray-200/80 px-6 py-6 dark:border-slate-800">
<div className="inline-flex items-center gap-3 rounded-2xl border border-brand-200/70 bg-gradient-to-br from-white to-brand-50 px-4 py-3 shadow-sm dark:border-brand-900/50 dark:from-slate-950 dark:to-slate-900">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-600 text-white shadow-lg shadow-brand-600/25">
<DashboardIcon />
</div>
<div>
{/* Logo */}
<div className={clsx(
"border-b border-gray-200/80 dark:border-slate-800",
sidebarCollapsed ? "px-3 py-4" : "px-6 py-6",
)}>
<div className={clsx(
"inline-flex items-center rounded-2xl border border-brand-200/70 bg-gradient-to-br from-white to-brand-50 shadow-sm dark:border-brand-900/50 dark:from-slate-950 dark:to-slate-900",
sidebarCollapsed ? "p-2" : "gap-3 px-4 py-3",
)}>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-brand-600 text-white shadow-lg shadow-brand-600/25">
<DashboardIcon />
</div>
{!sidebarCollapsed && (
<div className="overflow-hidden">
<h1 className="font-display text-xl font-semibold text-gray-900 dark:text-gray-50">
Pl<span className="text-brand-600">anarchy</span>
</h1>
<p className="text-xs uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">Resource Planning</p>
</div>
</div>
)}
</div>
</div>
{/* Nav links */}
<div className="flex-1 overflow-y-auto px-4 py-5">
{visibleSections.map((section, idx) => {
const isCollapsed = collapsedSections[section.label] ?? false;
const isCollapsible = section.collapsed === true;
{/* Nav links */}
<div className={clsx("flex-1 overflow-y-auto py-5", sidebarCollapsed ? "px-2" : "px-4")}>
{visibleSections.map((section, idx) => {
const isCollapsed = collapsedSections[section.label] ?? false;
const isCollapsible = section.collapsed === true;
return (
<div key={section.label} className={idx > 0 ? "mt-2" : ""}>
return (
<div key={section.label} className={idx > 0 ? "mt-2" : ""}>
{/* Section header — hidden when sidebar collapsed */}
{!sidebarCollapsed && (
<button
type="button"
onClick={isCollapsible ? () => toggleSection(section.label) : undefined}
@@ -274,24 +353,33 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
</svg>
)}
</button>
<AnimatePresence initial={false}>
{!isCollapsed && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="space-y-0.5">
{section.items.map((item) => {
const isActive = activeHrefSet.has(item.href);
return (
)}
{/* Collapsed sidebar: show a thin divider between sections instead of header */}
{sidebarCollapsed && idx > 0 && (
<div className="mx-3 my-2 border-t border-gray-200/60 dark:border-slate-800" />
)}
<AnimatePresence initial={false}>
{(!isCollapsed || sidebarCollapsed) && (
<motion.div
initial={sidebarCollapsed ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="space-y-0.5">
{section.items.map((item) => {
const isActive = activeHrefSet.has(item.href);
return (
<NavTooltip key={item.href} label={item.label} show={sidebarCollapsed}>
<Link
key={item.href}
href={item.href as Route}
onClick={handleLinkClick}
className={clsx(
"group relative flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-colors",
"group relative flex items-center rounded-2xl text-sm font-medium transition-colors",
sidebarCollapsed ? "justify-center px-2 py-2" : "gap-3 px-3 py-2",
isActive
? "text-brand-800 dark:text-brand-200"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
@@ -305,98 +393,139 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
/>
)}
<IconFrame>{item.icon}</IconFrame>
<span className="relative flex-1">{item.label}</span>
{!sidebarCollapsed && <span className="relative flex-1">{item.label}</span>}
</Link>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</NavTooltip>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
{showManagerSection && showAdmin && (
<div className="mt-2">
{showManagerSection && showAdmin && (
<div className="mt-2">
{!sidebarCollapsed && (
<div className="px-3 pb-1.5 pt-3">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500">
Admin
</span>
</div>
<div className="space-y-0.5">
{adminNavEntries.map((entry) => {
if (isSubGroup(entry)) {
const subCollapsed = collapsedSections[entry.label] ?? false;
return (
<div key={entry.label}>
<button
type="button"
onClick={() => toggleSection(entry.label)}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium text-gray-500 transition-all hover:bg-gray-100/90 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-slate-900 dark:hover:text-gray-200"
>
<IconFrame><AdminIcon /></IconFrame>
<span className="flex-1 text-left">{entry.label}</span>
<svg
)}
{sidebarCollapsed && (
<div className="mx-3 my-2 border-t border-gray-200/60 dark:border-slate-800" />
)}
<div className="space-y-0.5">
{adminNavEntries.map((entry) => {
if (isSubGroup(entry)) {
const subCollapsed = collapsedSections[entry.label] ?? false;
if (sidebarCollapsed) {
// In collapsed mode, show sub-group items directly as icon-only
return entry.items.map((item) => {
const isActive = activeHrefSet.has(item.href);
return (
<NavTooltip key={item.href} label={item.label} show>
<Link
href={item.href as Route}
onClick={handleLinkClick}
className={clsx(
"h-3 w-3 transition-transform",
subCollapsed ? "-rotate-90" : "rotate-0",
"group relative flex items-center justify-center rounded-2xl px-2 py-2 text-sm font-medium transition-colors",
isActive
? "text-brand-800 dark:text-brand-200"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<AnimatePresence initial={false}>
{!subCollapsed && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="ml-4 space-y-0.5 border-l border-gray-200 pl-2 dark:border-slate-800">
{entry.items.map((item) => {
const isActive = activeHrefSet.has(item.href);
return (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"group relative flex items-center gap-3 rounded-2xl px-3 py-1.5 text-sm font-medium transition-colors",
isActive
? "text-brand-800 dark:text-brand-200"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{isActive && (
<motion.div
layoutId="nav-indicator"
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-100 to-brand-50/80 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-900/30 dark:to-brand-800/20 dark:ring-brand-900/40"
transition={{ type: "spring", stiffness: 350, damping: 30 }}
/>
)}
<span className="relative flex-1">{item.label}</span>
</Link>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
{isActive && (
<motion.div
layoutId="nav-indicator"
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-100 to-brand-50/80 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-900/30 dark:to-brand-800/20 dark:ring-brand-900/40"
transition={{ type: "spring", stiffness: 350, damping: 30 }}
/>
)}
<IconFrame>{item.icon}</IconFrame>
</Link>
</NavTooltip>
);
});
}
const isActive = activeHrefSet.has(entry.href);
return (
<div key={entry.label}>
<button
type="button"
onClick={() => toggleSection(entry.label)}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium text-gray-500 transition-all hover:bg-gray-100/90 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-slate-900 dark:hover:text-gray-200"
>
<IconFrame><AdminIcon /></IconFrame>
<span className="flex-1 text-left">{entry.label}</span>
<svg
className={clsx(
"h-3 w-3 transition-transform",
subCollapsed ? "-rotate-90" : "rotate-0",
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<AnimatePresence initial={false}>
{!subCollapsed && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="ml-4 space-y-0.5 border-l border-gray-200 pl-2 dark:border-slate-800">
{entry.items.map((item) => {
const isActive = activeHrefSet.has(item.href);
return (
<Link
key={item.href}
href={item.href as Route}
onClick={handleLinkClick}
className={clsx(
"group relative flex items-center gap-3 rounded-2xl px-3 py-1.5 text-sm font-medium transition-colors",
isActive
? "text-brand-800 dark:text-brand-200"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{isActive && (
<motion.div
layoutId="nav-indicator"
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-100 to-brand-50/80 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-900/30 dark:to-brand-800/20 dark:ring-brand-900/40"
transition={{ type: "spring", stiffness: 350, damping: 30 }}
/>
)}
<span className="relative flex-1">{item.label}</span>
</Link>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
const isActive = activeHrefSet.has(entry.href);
return (
<NavTooltip key={entry.href} label={entry.label} show={sidebarCollapsed}>
<Link
key={entry.href}
href={entry.href as Route}
onClick={handleLinkClick}
className={clsx(
"group relative flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-colors",
"group relative flex items-center rounded-2xl text-sm font-medium transition-colors",
sidebarCollapsed ? "justify-center px-2 py-2" : "gap-3 px-3 py-2",
isActive
? "text-brand-800 dark:text-brand-200"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
@@ -410,37 +539,59 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
/>
)}
<IconFrame>{entry.icon}</IconFrame>
<span className="relative flex-1">{entry.label}</span>
{!sidebarCollapsed && <span className="relative flex-1">{entry.label}</span>}
</Link>
);
})}
</div>
</NavTooltip>
);
})}
</div>
)}
</div>
{/* Bottom actions */}
<div className="space-y-1 border-t border-gray-200/80 p-4 dark:border-slate-800">
<div className="flex items-center gap-2 px-3 py-2">
<NotificationBell />
<span className="text-xs uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500">Notifications</span>
</div>
)}
</div>
{/* Bottom actions */}
<div className={clsx(
"space-y-1 border-t border-gray-200/80 dark:border-slate-800",
sidebarCollapsed ? "p-2" : "p-4",
)}>
<NavTooltip label="Notifications" show={sidebarCollapsed}>
<div className={clsx(
"flex items-center",
sidebarCollapsed ? "justify-center px-2 py-2" : "gap-2 px-3 py-2",
)}>
<NotificationBell />
{!sidebarCollapsed && (
<span className="text-xs uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500">Notifications</span>
)}
</div>
</NavTooltip>
<NavTooltip label="HartBOT" show={sidebarCollapsed}>
<button
type="button"
onClick={onChatOpen}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-100/90 dark:text-gray-300 dark:hover:bg-slate-900"
className={clsx(
"flex w-full items-center rounded-2xl text-sm text-gray-700 transition-colors hover:bg-gray-100/90 dark:text-gray-300 dark:hover:bg-slate-900",
sidebarCollapsed ? "justify-center px-2 py-2.5" : "gap-3 px-3 py-2.5",
)}
>
<IconFrame>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</IconFrame>
<span>HartBOT</span>
{!sidebarCollapsed && <span>HartBOT</span>}
</button>
</NavTooltip>
<NavTooltip label="Preferences" show={sidebarCollapsed}>
<button
type="button"
onClick={() => setPrefsOpen(true)}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-100/90 dark:text-gray-300 dark:hover:bg-slate-900"
className={clsx(
"flex w-full items-center rounded-2xl text-sm text-gray-700 transition-colors hover:bg-gray-100/90 dark:text-gray-300 dark:hover:bg-slate-900",
sidebarCollapsed ? "justify-center px-2 py-2.5" : "gap-3 px-3 py-2.5",
)}
>
<IconFrame>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -448,30 +599,189 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</IconFrame>
<span>Preferences</span>
{!sidebarCollapsed && <span>Preferences</span>}
</button>
</NavTooltip>
<NavTooltip label="Sign out" show={sidebarCollapsed}>
<button
type="button"
onClick={() => void signOut({ callbackUrl: "/auth/signin" })}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-100/90 dark:text-gray-300 dark:hover:bg-slate-900"
className={clsx(
"flex w-full items-center rounded-2xl text-sm text-gray-700 transition-colors hover:bg-gray-100/90 dark:text-gray-300 dark:hover:bg-slate-900",
sidebarCollapsed ? "justify-center px-2 py-2.5" : "gap-3 px-3 py-2.5",
)}
>
<IconFrame>
<svg className="h-4 w-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>
</IconFrame>
<span>Sign out</span>
{!sidebarCollapsed && <span>Sign out</span>}
</button>
</div>
</nav>
</NavTooltip>
{/* Collapse toggle */}
<NavTooltip label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} show={sidebarCollapsed}>
<button
type="button"
onClick={onToggleCollapse}
className={clsx(
"flex w-full items-center rounded-2xl text-sm text-gray-400 transition-colors hover:bg-gray-100/90 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-slate-900 dark:hover:text-gray-300",
sidebarCollapsed ? "justify-center px-2 py-2.5" : "gap-3 px-3 py-2.5",
)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
<CollapseIcon collapsed={sidebarCollapsed} />
</span>
{!sidebarCollapsed && <span>Collapse</span>}
</button>
</NavTooltip>
</div>
{prefsOpen && <PreferencesModal onClose={() => setPrefsOpen(false)} />}
</>
);
}
/* ------------------------------------------------------------------ */
/* Desktop sidebar wrapper */
/* ------------------------------------------------------------------ */
function DesktopSidebar({
userRole,
onChatOpen,
sidebarCollapsed,
onToggleCollapse,
}: {
userRole: string;
onChatOpen: () => void;
sidebarCollapsed: boolean;
onToggleCollapse: () => void;
}) {
return (
<nav
className={clsx(
"hidden shrink-0 border-r border-gray-200/60 bg-white/60 shadow-[1px_0_12px_rgba(0,0,0,0.03)] backdrop-blur-2xl backdrop-saturate-150 dark:border-slate-800 dark:bg-slate-950/75 dark:shadow-none lg:flex lg:flex-col",
"transition-[width] duration-200 ease-out overflow-hidden",
sidebarCollapsed ? "w-[72px]" : "w-72",
)}
>
<SidebarContent
userRole={userRole}
onChatOpen={onChatOpen}
sidebarCollapsed={sidebarCollapsed}
onToggleCollapse={onToggleCollapse}
/>
</nav>
);
}
/* ------------------------------------------------------------------ */
/* Mobile sidebar overlay */
/* ------------------------------------------------------------------ */
function MobileSidebar({
open,
onClose,
userRole,
onChatOpen,
}: {
open: boolean;
onClose: () => void;
userRole: string;
onChatOpen: () => void;
}) {
return (
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm lg:hidden"
onClick={onClose}
/>
{/* Slide-in sidebar */}
<motion.nav
initial={{ x: -288 }}
animate={{ x: 0 }}
exit={{ x: -288 }}
transition={{ type: "spring", stiffness: 400, damping: 35 }}
className="fixed inset-y-0 left-0 z-50 flex w-72 flex-col border-r border-gray-200/60 bg-white/95 shadow-2xl backdrop-blur-2xl backdrop-saturate-150 dark:border-slate-800 dark:bg-slate-950/95 lg:hidden"
>
{/* Close button */}
<button
type="button"
onClick={onClose}
className="absolute right-3 top-5 z-10 flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-slate-800 dark:hover:text-gray-300"
>
<CloseIcon />
</button>
<SidebarContent
userRole={userRole}
onChatOpen={() => {
onChatOpen();
onClose();
}}
sidebarCollapsed={false}
onToggleCollapse={() => {}}
onNavClick={onClose}
/>
</motion.nav>
</>
)}
</AnimatePresence>
);
}
/* ------------------------------------------------------------------ */
/* AppShell (main export) */
/* ------------------------------------------------------------------ */
export function AppShell({ children, userRole = "USER" }: { children: React.ReactNode; userRole?: string }) {
const [chatOpen, setChatOpen] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const pathname = usePathname();
const contentRef = useRef<HTMLElement>(null);
// Read collapsed state from localStorage on mount (avoid SSR hydration mismatch)
useEffect(() => {
try {
const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
if (stored === "true") setSidebarCollapsed(true);
} catch {
// localStorage unavailable — ignore
}
}, []);
// Persist collapsed state
const handleToggleCollapse = useCallback(() => {
setSidebarCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
} catch {
// ignore
}
return next;
});
}, []);
// Scroll to top on route change
useEffect(() => {
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, [pathname]);
// Close mobile sidebar on route change
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
return (
<ThemeProvider>
@@ -479,13 +789,42 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
<NavProgressBar />
</Suspense>
<div className="flex h-screen bg-transparent">
<Sidebar userRole={userRole} onChatOpen={() => setChatOpen(true)} />
<main className="flex-1 overflow-auto bg-transparent">
{/* Desktop sidebar */}
<DesktopSidebar
userRole={userRole}
onChatOpen={() => setChatOpen(true)}
sidebarCollapsed={sidebarCollapsed}
onToggleCollapse={handleToggleCollapse}
/>
{/* Mobile sidebar overlay */}
<MobileSidebar
open={mobileOpen}
onClose={() => setMobileOpen(false)}
userRole={userRole}
onChatOpen={() => setChatOpen(true)}
/>
{/* Main content area */}
<main ref={contentRef} className="flex-1 overflow-auto bg-transparent">
{/* Mobile hamburger */}
<div className="sticky top-0 z-20 flex items-center border-b border-gray-200/60 bg-white/80 px-4 py-2 backdrop-blur-lg dark:border-slate-800 dark:bg-slate-950/80 lg:hidden">
<button
type="button"
onClick={() => setMobileOpen(true)}
className="flex h-9 w-9 items-center justify-center rounded-lg text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-slate-800"
>
<HamburgerIcon />
</button>
<span className="ml-3 font-display text-sm font-semibold text-gray-900 dark:text-gray-50">
Pl<span className="text-brand-600">anarchy</span>
</span>
</div>
<PageTransition>{children}</PageTransition>
</main>
{chatOpen && <ChatPanel onClose={() => setChatOpen(false)} />}
</div>
{/* Floating chat FAB — always visible when panel is closed */}
{/* Floating chat FAB */}
{!chatOpen && (
<button
type="button"
@@ -3,6 +3,7 @@
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 {
@@ -27,7 +28,12 @@ export function PreferencesModal({ onClose }: PreferencesModalProps) {
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">
<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>
@@ -307,7 +313,7 @@ export function PreferencesModal({ onClose }: PreferencesModalProps) {
Changes apply instantly and are saved in your browser.
</p>
</div>
</div>
</motion.div>
</div>
);
}