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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user