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
@@ -7,6 +7,7 @@ import type { Project, ColumnDef } from "@planarchy/shared";
import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared"; import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared";
import Link from "next/link"; import Link from "next/link";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { motion } from "framer-motion";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { ProjectModal } from "~/components/projects/ProjectModal.js"; import { ProjectModal } from "~/components/projects/ProjectModal.js";
import { ProjectWizard } from "~/components/projects/ProjectWizard.js"; import { ProjectWizard } from "~/components/projects/ProjectWizard.js";
@@ -116,9 +117,12 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
</svg> </svg>
</button> </button>
{isOpen && createPortal( {isOpen && createPortal(
<div <motion.div
ref={panelRef} ref={panelRef}
className="fixed z-[9999] min-w-[160px] rounded-2xl border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-900" initial={{ opacity: 0, scaleY: 0.9 }}
animate={{ opacity: 1, scaleY: 1 }}
transition={{ duration: 0.12, ease: "easeOut" }}
className="fixed z-[9999] min-w-[160px] rounded-2xl border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-900 origin-top"
style={{ top: pos.top, left: pos.left }} style={{ top: pos.top, left: pos.left }}
> >
{ALL_STATUSES.map((s) => ( {ALL_STATUSES.map((s) => (
@@ -139,7 +143,7 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
</span> </span>
</button> </button>
))} ))}
</div>, </motion.div>,
document.body, document.body,
)} )}
</> </>
@@ -575,7 +579,7 @@ export function ProjectsClient() {
id={project.id} id={project.id}
dragRef={rowDragRef} dragRef={rowDragRef}
onDrop={(draggedId) => reorder(draggedId, project.id)} onDrop={(draggedId) => reorder(draggedId, project.id)}
className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} className={`table-row-hover hover-lift hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}
style={{ animationDelay: `${Math.min(index * 15, 300)}ms` }} style={{ animationDelay: `${Math.min(index * 15, 300)}ms` }}
> >
<td className="px-2 py-3 w-8"> <td className="px-2 py-3 w-8">
@@ -602,11 +606,11 @@ export function ProjectsClient() {
<button <button
type="button" type="button"
onClick={() => openEditModal(project as unknown as Project)} onClick={() => openEditModal(project as unknown as Project)}
className="text-xs font-medium text-gray-600 transition-colors hover:text-gray-900 hover:underline dark:text-gray-300 dark:hover:text-gray-100" className="link-hover-underline text-xs font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
> >
Edit Edit
</button> </button>
<Link href={`/projects/${project.id}`} className="text-xs font-medium text-blue-600 hover:text-blue-800 hover:underline dark:text-blue-300 dark:hover:text-blue-200"> <Link href={`/projects/${project.id}`} className="link-hover-underline text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-200">
View View
</Link> </Link>
</div> </div>
@@ -1114,7 +1114,7 @@ export function ResourcesClient() {
id={resource.id} id={resource.id}
dragRef={rowDragRef} dragRef={rowDragRef}
onDrop={(draggedId) => reorder(draggedId, resource.id)} onDrop={(draggedId) => reorder(draggedId, resource.id)}
className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} className={`table-row-hover hover-lift hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}
style={{ animationDelay: `${Math.min(index * 15, 300)}ms` }} style={{ animationDelay: `${Math.min(index * 15, 300)}ms` }}
> >
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -1386,7 +1386,7 @@ export function ResourcesClient() {
onClick={() => onClick={() =>
setModal({ type: "edit", resource: resource as unknown as Resource }) setModal({ type: "edit", resource: resource as unknown as Resource })
} }
className="mr-3 text-xs font-medium text-brand-600 transition-colors hover:text-brand-800 dark:text-brand-300 dark:hover:text-brand-100" className="link-hover-underline mr-3 text-xs font-medium text-brand-600 transition-colors hover:text-brand-800 dark:text-brand-300 dark:hover:text-brand-100"
> >
Edit Edit
</button> </button>
+48 -2
View File
@@ -437,11 +437,12 @@
.allocation-block { .allocation-block {
@apply absolute rounded-md text-xs font-medium px-2 py-1 cursor-pointer select-none; @apply absolute rounded-md text-xs font-medium px-2 py-1 cursor-pointer select-none;
@apply transition-all duration-150 ease-in-out; transition: transform 0.1s ease-out, box-shadow 0.1s ease-out, opacity 0.15s ease-in-out;
} }
.allocation-block:hover { .allocation-block:hover {
@apply ring-2 ring-white ring-offset-1; @apply ring-2 ring-white ring-offset-1 shadow-md;
transform: scale(1.02);
} }
.allocation-block.dragging { .allocation-block.dragging {
@@ -510,3 +511,48 @@
:is(.dark) .hover-lift:hover { :is(.dark) .hover-lift:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
} }
/* ─── Smooth scroll + reduced-motion accessibility ────────────────────────── */
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ─── Table row hover accent border ──────────────────────────────────────── */
.table-row-hover {
border-left: 2px solid transparent;
transition: border-color 0.15s ease-out, background-color 0.15s ease-out, transform 0.15s ease-out, box-shadow 0.15s ease-out;
}
.table-row-hover:hover {
border-left-color: rgb(var(--accent-400));
}
/* ─── Animated underline for action links ─────────────────────────────────── */
.link-hover-underline {
position: relative;
}
.link-hover-underline::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
width: 0;
height: 1px;
background: currentColor;
transition: width 0.2s ease-out;
}
.link-hover-underline:hover::after {
width: 100%;
}
@@ -21,6 +21,7 @@ import { useColumnConfig } from "~/hooks/useColumnConfig.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js"; import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js"; import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
import { SuccessToast } from "~/components/ui/SuccessToast.js";
/** Left-border color by allocation status for instant visual scanning */ /** Left-border color by allocation status for instant visual scanning */
const STATUS_LEFT_BORDER: Record<string, string> = { const STATUS_LEFT_BORDER: Record<string, string> = {
@@ -63,6 +64,7 @@ export function AllocationsClient() {
const [confirmDelete, setConfirmDelete] = useState<{ single?: AllocationWithDetails; ids?: string[] } | null>(null); const [confirmDelete, setConfirmDelete] = useState<{ single?: AllocationWithDetails; ids?: string[] } | null>(null);
const [batchStatusPicker, setBatchStatusPicker] = useState(false); const [batchStatusPicker, setBatchStatusPicker] = useState(false);
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null); const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
const [showStatusToast, setShowStatusToast] = useState(false);
const selection = useSelection(); const selection = useSelection();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -113,6 +115,7 @@ export function AllocationsClient() {
await utils.allocation.list.invalidate(); await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate(); await utils.allocation.listView.invalidate();
selection.clear(); selection.clear();
setShowStatusToast(true);
}, },
}); });
@@ -438,6 +441,7 @@ export function AllocationsClient() {
return ( return (
<div className="app-page space-y-5 pb-24"> <div className="app-page space-y-5 pb-24">
<SuccessToast show={showStatusToast} message="Allocation status updated" onDone={() => setShowStatusToast(false)} />
<div className="app-page-header gap-4"> <div className="app-page-header gap-4">
<div> <div>
<h1 className="app-page-title">Allocations</h1> <h1 className="app-page-title">Allocations</h1>
@@ -15,8 +15,8 @@ export function WidgetContainer({ title, onRemove, children, isDragging }: Widge
initial={{ opacity: 0, y: 16 }} initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }} transition={{ duration: 0.35, ease: "easeOut" }}
className={`flex flex-col h-full bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden ${ className={`flex flex-col h-full bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden transition-colors duration-200 ${
isDragging ? "shadow-lg border-brand-300" : "" isDragging ? "shadow-lg border-brand-300" : "hover:border-brand-200 dark:hover:border-brand-800"
}`} }`}
> >
{/* Header */} {/* Header */}
@@ -44,7 +44,7 @@ function StatCard({
return ( return (
<FadeIn delay={delay} direction="up"> <FadeIn delay={delay} direction="up">
<div <div
className={`rounded-2xl border border-gray-200 bg-white/80 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/70 ${ className={`rounded-2xl border border-gray-200 bg-white/80 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/70 hover-lift cursor-default ${
accentColor ? `border-l-[3px] ${accentBorder}` : "" accentColor ? `border-l-[3px] ${accentBorder}` : ""
}`} }`}
> >
+374 -35
View File
@@ -6,7 +6,7 @@ import Link from "next/link";
import type { Route } from "next"; import type { Route } from "next";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { clsx } from "clsx"; 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 { motion, AnimatePresence } from "framer-motion";
import { PreferencesModal } from "./PreferencesModal.js"; import { PreferencesModal } from "./PreferencesModal.js";
import { ThemeProvider } from "./ThemeProvider.js"; import { ThemeProvider } from "./ThemeProvider.js";
@@ -15,9 +15,11 @@ import { NotificationBell } from "../notifications/NotificationBell.js";
import { ChatPanel } from "../assistant/ChatPanel.js"; import { ChatPanel } from "../assistant/ChatPanel.js";
import { NavProgressBar } from "~/components/ui/NavProgressBar.js"; import { NavProgressBar } from "~/components/ui/NavProgressBar.js";
const SIDEBAR_COLLAPSED_KEY = "planarchy_sidebar_collapsed";
function IconFrame({ children }: { children: ReactNode }) { function IconFrame({ children }: { children: ReactNode }) {
return ( 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} {children}
</span> </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>; 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 NavItem = { href: string; label: string; icon: ReactNode; roles: string[] };
type NavSection = { label: string; collapsed?: boolean; items: NavItem[] }; 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 * Collect every href registered in the sidebar so that the active-check
* can determine whether a more-specific sibling matches the current path. * 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 ALL_NAV_HREFS: string[] = (() => {
const hrefs: string[] = []; const hrefs: string[] = [];
@@ -171,19 +200,57 @@ const ALL_NAV_HREFS: string[] = (() => {
function isNavItemActive(pathname: string, href: string): boolean { function isNavItemActive(pathname: string, href: string): boolean {
if (pathname === href) return true; if (pathname === href) return true;
if (!pathname.startsWith(href + "/")) return false; 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( const hasMoreSpecificSibling = ALL_NAV_HREFS.some(
(other) => other !== href && other.length > href.length && pathname.startsWith(other), (other) => other !== href && other.length > href.length && pathname.startsWith(other),
); );
return !hasMoreSpecificSibling; 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 pathname = usePathname();
const [prefsOpen, setPrefsOpen] = useState(false); const [prefsOpen, setPrefsOpen] = useState(false);
// Memoize active href set — avoids O(n²) on every render
const activeHrefSet = useMemo(() => { const activeHrefSet = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
for (const href of ALL_NAV_HREFS) { for (const href of ALL_NAV_HREFS) {
@@ -201,7 +268,6 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
const showAdmin = userRole === "ADMIN"; const showAdmin = userRole === "ADMIN";
const showManagerSection = userRole === "ADMIN" || userRole === "MANAGER"; 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 [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {}; const initial: Record<string, boolean> = {};
for (const section of visibleSections) { for (const section of visibleSections) {
@@ -223,32 +289,45 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
setCollapsedSections((prev) => ({ ...prev, [label]: !prev[label] })); setCollapsedSections((prev) => ({ ...prev, [label]: !prev[label] }));
}; };
const handleLinkClick = () => {
onNavClick?.();
};
return ( 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 */} {/* Logo */}
<div className="border-b border-gray-200/80 px-6 py-6 dark:border-slate-800"> <div className={clsx(
<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"> "border-b border-gray-200/80 dark:border-slate-800",
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-600 text-white shadow-lg shadow-brand-600/25"> 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 /> <DashboardIcon />
</div> </div>
<div> {!sidebarCollapsed && (
<div className="overflow-hidden">
<h1 className="font-display text-xl font-semibold text-gray-900 dark:text-gray-50"> <h1 className="font-display text-xl font-semibold text-gray-900 dark:text-gray-50">
Pl<span className="text-brand-600">anarchy</span> Pl<span className="text-brand-600">anarchy</span>
</h1> </h1>
<p className="text-xs uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">Resource Planning</p> <p className="text-xs uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">Resource Planning</p>
</div> </div>
)}
</div> </div>
</div> </div>
{/* Nav links */} {/* Nav links */}
<div className="flex-1 overflow-y-auto px-4 py-5"> <div className={clsx("flex-1 overflow-y-auto py-5", sidebarCollapsed ? "px-2" : "px-4")}>
{visibleSections.map((section, idx) => { {visibleSections.map((section, idx) => {
const isCollapsed = collapsedSections[section.label] ?? false; const isCollapsed = collapsedSections[section.label] ?? false;
const isCollapsible = section.collapsed === true; const isCollapsible = section.collapsed === true;
return ( return (
<div key={section.label} className={idx > 0 ? "mt-2" : ""}> <div key={section.label} className={idx > 0 ? "mt-2" : ""}>
{/* Section header — hidden when sidebar collapsed */}
{!sidebarCollapsed && (
<button <button
type="button" type="button"
onClick={isCollapsible ? () => toggleSection(section.label) : undefined} onClick={isCollapsible ? () => toggleSection(section.label) : undefined}
@@ -274,10 +353,17 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
</svg> </svg>
)} )}
</button> </button>
)}
{/* 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}> <AnimatePresence initial={false}>
{!isCollapsed && ( {(!isCollapsed || sidebarCollapsed) && (
<motion.div <motion.div
initial={{ height: 0, opacity: 0 }} initial={sidebarCollapsed ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }} animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }} exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }} transition={{ duration: 0.2, ease: "easeInOut" }}
@@ -287,11 +373,13 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
{section.items.map((item) => { {section.items.map((item) => {
const isActive = activeHrefSet.has(item.href); const isActive = activeHrefSet.has(item.href);
return ( return (
<NavTooltip key={item.href} label={item.label} show={sidebarCollapsed}>
<Link <Link
key={item.href}
href={item.href as Route} href={item.href as Route}
onClick={handleLinkClick}
className={clsx( 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 isActive
? "text-brand-800 dark:text-brand-200" ? "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", : "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,8 +393,9 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
/> />
)} )}
<IconFrame>{item.icon}</IconFrame> <IconFrame>{item.icon}</IconFrame>
<span className="relative flex-1">{item.label}</span> {!sidebarCollapsed && <span className="relative flex-1">{item.label}</span>}
</Link> </Link>
</NavTooltip>
); );
})} })}
</div> </div>
@@ -319,15 +408,51 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
{showManagerSection && showAdmin && ( {showManagerSection && showAdmin && (
<div className="mt-2"> <div className="mt-2">
{!sidebarCollapsed && (
<div className="px-3 pb-1.5 pt-3"> <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"> <span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500">
Admin Admin
</span> </span>
</div> </div>
)}
{sidebarCollapsed && (
<div className="mx-3 my-2 border-t border-gray-200/60 dark:border-slate-800" />
)}
<div className="space-y-0.5"> <div className="space-y-0.5">
{adminNavEntries.map((entry) => { {adminNavEntries.map((entry) => {
if (isSubGroup(entry)) { if (isSubGroup(entry)) {
const subCollapsed = collapsedSections[entry.label] ?? false; 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(
"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",
)}
>
{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>
);
});
}
return ( return (
<div key={entry.label}> <div key={entry.label}>
<button <button
@@ -365,6 +490,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
<Link <Link
key={item.href} key={item.href}
href={item.href as Route} href={item.href as Route}
onClick={handleLinkClick}
className={clsx( className={clsx(
"group relative flex items-center gap-3 rounded-2xl px-3 py-1.5 text-sm font-medium transition-colors", "group relative flex items-center gap-3 rounded-2xl px-3 py-1.5 text-sm font-medium transition-colors",
isActive isActive
@@ -390,13 +516,16 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
</div> </div>
); );
} }
const isActive = activeHrefSet.has(entry.href); const isActive = activeHrefSet.has(entry.href);
return ( return (
<NavTooltip key={entry.href} label={entry.label} show={sidebarCollapsed}>
<Link <Link
key={entry.href}
href={entry.href as Route} href={entry.href as Route}
onClick={handleLinkClick}
className={clsx( 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 isActive
? "text-brand-800 dark:text-brand-200" ? "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", : "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,8 +539,9 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
/> />
)} )}
<IconFrame>{entry.icon}</IconFrame> <IconFrame>{entry.icon}</IconFrame>
<span className="relative flex-1">{entry.label}</span> {!sidebarCollapsed && <span className="relative flex-1">{entry.label}</span>}
</Link> </Link>
</NavTooltip>
); );
})} })}
</div> </div>
@@ -420,27 +550,48 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
</div> </div>
{/* Bottom actions */} {/* Bottom actions */}
<div className="space-y-1 border-t border-gray-200/80 p-4 dark:border-slate-800"> <div className={clsx(
<div className="flex items-center gap-2 px-3 py-2"> "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 /> <NotificationBell />
{!sidebarCollapsed && (
<span className="text-xs uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500">Notifications</span> <span className="text-xs uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500">Notifications</span>
)}
</div> </div>
</NavTooltip>
<NavTooltip label="HartBOT" show={sidebarCollapsed}>
<button <button
type="button" type="button"
onClick={onChatOpen} 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> <IconFrame>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
</IconFrame> </IconFrame>
<span>HartBOT</span> {!sidebarCollapsed && <span>HartBOT</span>}
</button> </button>
</NavTooltip>
<NavTooltip label="Preferences" show={sidebarCollapsed}>
<button <button
type="button" type="button"
onClick={() => setPrefsOpen(true)} 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> <IconFrame>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
</IconFrame> </IconFrame>
<span>Preferences</span> {!sidebarCollapsed && <span>Preferences</span>}
</button> </button>
</NavTooltip>
<NavTooltip label="Sign out" show={sidebarCollapsed}>
<button <button
type="button" type="button"
onClick={() => void signOut({ callbackUrl: "/auth/signin" })} 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> <IconFrame>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
</IconFrame> </IconFrame>
<span>Sign out</span> {!sidebarCollapsed && <span>Sign out</span>}
</button> </button>
</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> </div>
</nav>
{prefsOpen && <PreferencesModal onClose={() => setPrefsOpen(false)} />} {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 }) { export function AppShell({ children, userRole = "USER" }: { children: React.ReactNode; userRole?: string }) {
const [chatOpen, setChatOpen] = useState(false); 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 ( return (
<ThemeProvider> <ThemeProvider>
@@ -479,13 +789,42 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
<NavProgressBar /> <NavProgressBar />
</Suspense> </Suspense>
<div className="flex h-screen bg-transparent"> <div className="flex h-screen bg-transparent">
<Sidebar userRole={userRole} onChatOpen={() => setChatOpen(true)} /> {/* Desktop sidebar */}
<main className="flex-1 overflow-auto bg-transparent"> <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> <PageTransition>{children}</PageTransition>
</main> </main>
{chatOpen && <ChatPanel onClose={() => setChatOpen(false)} />} {chatOpen && <ChatPanel onClose={() => setChatOpen(false)} />}
</div> </div>
{/* Floating chat FAB — always visible when panel is closed */} {/* Floating chat FAB */}
{!chatOpen && ( {!chatOpen && (
<button <button
type="button" type="button"
@@ -3,6 +3,7 @@
import { useTheme } from "~/hooks/useTheme.js"; import { useTheme } from "~/hooks/useTheme.js";
import type { AccentColor, ThemeMode } from "~/hooks/useTheme.js"; import type { AccentColor, ThemeMode } from "~/hooks/useTheme.js";
import { useAppPreferences, type HeatmapColorScheme } from "~/hooks/useAppPreferences.js"; import { useAppPreferences, type HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
import { motion } from "framer-motion";
import { clsx } from "clsx"; import { clsx } from "clsx";
interface PreferencesModalProps { 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" 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(); }} 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 */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <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> <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. Changes apply instantly and are saved in your browser.
</p> </p>
</div> </div>
</div> </motion.div>
</div> </div>
); );
} }
@@ -192,9 +192,12 @@ export function NotificationBell() {
{/* Dropdown panel — rendered via portal to escape sidebar overflow */} {/* Dropdown panel — rendered via portal to escape sidebar overflow */}
{open && createPortal( {open && createPortal(
<div <motion.div
ref={dropdownRef} ref={dropdownRef}
className="fixed w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-[9999] overflow-hidden" initial={{ opacity: 0, scaleY: 0.95, scaleX: 0.98 }}
animate={{ opacity: 1, scaleY: 1, scaleX: 1 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="fixed w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-[9999] overflow-hidden origin-top"
style={{ top: dropdownPos.top, left: dropdownPos.left }} style={{ top: dropdownPos.top, left: dropdownPos.left }}
> >
{/* Header */} {/* Header */}
@@ -383,7 +386,7 @@ export function NotificationBell() {
View all &rarr; View all &rarr;
</Link> </Link>
</div> </div>
</div>, </motion.div>,
document.body, document.body,
)} )}
</div> </div>
@@ -11,6 +11,8 @@ import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
import { usePermissions } from "~/hooks/usePermissions.js"; import { usePermissions } from "~/hooks/usePermissions.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatCents } from "~/lib/format.js"; import { formatCents } from "~/lib/format.js";
import { ConfettiBurst } from "~/components/ui/ConfettiBurst.js";
import { SuccessToast } from "~/components/ui/SuccessToast.js";
// ─── Constants ──────────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
@@ -1023,6 +1025,8 @@ export function ProjectWizard({ open, onClose }: ProjectWizardProps) {
const [state, setState] = useState<WizardState>(makeDefaultState); const [state, setState] = useState<WizardState>(makeDefaultState);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [showConfetti, setShowConfetti] = useState(false);
const [showSuccessToast, setShowSuccessToast] = useState(false);
const createProject = trpc.project.create.useMutation(); const createProject = trpc.project.create.useMutation();
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1139,7 +1143,12 @@ export function ProjectWizard({ open, onClose }: ProjectWizardProps) {
await utils.project.list.invalidate(); await utils.project.list.invalidate();
await utils.timeline.getEntries.invalidate(); await utils.timeline.getEntries.invalidate();
await utils.timeline.getEntriesView.invalidate(); await utils.timeline.getEntriesView.invalidate();
setShowConfetti(true);
setShowSuccessToast(true);
setTimeout(() => {
setShowConfetti(false);
handleClose(); handleClose();
}, 1200);
} catch (err) { } catch (err) {
setSubmitError(err instanceof Error ? err.message : "Failed to create project"); setSubmitError(err instanceof Error ? err.message : "Failed to create project");
} finally { } finally {
@@ -1158,7 +1167,14 @@ export function ProjectWizard({ open, onClose }: ProjectWizardProps) {
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8 px-4" className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8 px-4"
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl"> <div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl relative">
{/* Celebration effects */}
<ConfettiBurst trigger={showConfetti} />
<SuccessToast
show={showSuccessToast}
message="Project created successfully!"
onDone={() => setShowSuccessToast(false)}
/>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">New Project Wizard</h2> <h2 className="text-lg font-semibold text-gray-900">New Project Wizard</h2>
@@ -0,0 +1,105 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
interface ConfettiBurstProps {
trigger: boolean;
particleCount?: number;
duration?: number;
colors?: string[];
}
const DEFAULT_COLORS = [
"#6366f1", // indigo
"#8b5cf6", // violet
"#ec4899", // pink
"#f59e0b", // amber
"#10b981", // emerald
"#3b82f6", // blue
];
const KEYFRAMES_ID = "confetti-burst-keyframes";
function ensureKeyframes() {
if (typeof document === "undefined") return;
if (document.getElementById(KEYFRAMES_ID)) return;
const style = document.createElement("style");
style.id = KEYFRAMES_ID;
style.textContent = `
@keyframes confetti-burst {
0% {
transform: translate(0, 0) rotate(0deg) scale(1);
opacity: 1;
}
100% {
transform: translate(var(--confetti-x), var(--confetti-y)) rotate(var(--confetti-r)) scale(0.3);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
export function ConfettiBurst({
trigger,
particleCount = 20,
duration = 800,
colors = DEFAULT_COLORS,
}: ConfettiBurstProps) {
const containerRef = useRef<HTMLDivElement>(null);
const prevTrigger = useRef(false);
const burst = useCallback(() => {
const container = containerRef.current;
if (!container) return;
ensureKeyframes();
const particles: HTMLDivElement[] = [];
for (let i = 0; i < particleCount; i++) {
const el = document.createElement("div");
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5;
const distance = 40 + Math.random() * 80;
const x = Math.cos(angle) * distance;
const y = Math.sin(angle) * distance - 20 + Math.random() * 60; // gravity bias
const rotation = Math.random() * 720 - 360;
el.style.cssText = `
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 6px;
margin: -3px 0 0 -3px;
border-radius: ${Math.random() > 0.5 ? "50%" : "1px"};
background: ${colors[i % colors.length]};
pointer-events: none;
--confetti-x: ${x}px;
--confetti-y: ${y}px;
--confetti-r: ${rotation}deg;
animation: confetti-burst ${duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
`;
container.appendChild(el);
particles.push(el);
}
setTimeout(() => {
particles.forEach((el) => el.remove());
}, duration + 50);
}, [particleCount, duration, colors]);
useEffect(() => {
if (trigger && !prevTrigger.current) {
burst();
}
prevTrigger.current = trigger;
}, [trigger, burst]);
return (
<div
ref={containerRef}
className="pointer-events-none absolute inset-0 overflow-visible"
aria-hidden="true"
/>
);
}
+20 -2
View File
@@ -1,3 +1,7 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
export interface Chip { export interface Chip {
label: string; label: string;
onRemove: () => void; onRemove: () => void;
@@ -8,14 +12,27 @@ interface FilterChipsProps {
onClearAll: () => void; onClearAll: () => void;
} }
const chipVariants = {
initial: { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.8 },
};
export function FilterChips({ chips, onClearAll }: FilterChipsProps) { export function FilterChips({ chips, onClearAll }: FilterChipsProps) {
if (chips.length === 0) return null; if (chips.length === 0) return null;
return ( return (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<AnimatePresence mode="popLayout">
{chips.map((chip) => ( {chips.map((chip) => (
<span <motion.span
key={chip.label} key={chip.label}
variants={chipVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.15, ease: "easeOut" }}
layout
className="inline-flex items-center gap-1 rounded-full bg-brand-50 text-brand-700 border border-brand-200 px-2.5 py-0.5 text-xs" className="inline-flex items-center gap-1 rounded-full bg-brand-50 text-brand-700 border border-brand-200 px-2.5 py-0.5 text-xs"
> >
{chip.label} {chip.label}
@@ -27,8 +44,9 @@ export function FilterChips({ chips, onClearAll }: FilterChipsProps) {
> >
× ×
</button> </button>
</span> </motion.span>
))} ))}
</AnimatePresence>
<button <button
type="button" type="button"
onClick={onClearAll} onClick={onClearAll}
@@ -0,0 +1,75 @@
"use client";
import { useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface SuccessToastProps {
show: boolean;
message: string;
onDone?: () => void;
variant?: "success" | "info" | "warning";
}
const VARIANT_STYLES = {
success: {
bg: "bg-emerald-50 dark:bg-emerald-950/80 border-emerald-200 dark:border-emerald-800",
text: "text-emerald-800 dark:text-emerald-200",
icon: (
<svg className="h-4 w-4 text-emerald-500 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
),
},
info: {
bg: "bg-blue-50 dark:bg-blue-950/80 border-blue-200 dark:border-blue-800",
text: "text-blue-800 dark:text-blue-200",
icon: (
<svg className="h-4 w-4 text-blue-500 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" strokeWidth={2} />
<path strokeLinecap="round" d="M12 16v-4M12 8h.01" />
</svg>
),
},
warning: {
bg: "bg-amber-50 dark:bg-amber-950/80 border-amber-200 dark:border-amber-800",
text: "text-amber-800 dark:text-amber-200",
icon: (
<svg className="h-4 w-4 text-amber-500 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
),
},
} as const;
export function SuccessToast({ show, message, onDone, variant = "success" }: SuccessToastProps) {
const style = VARIANT_STYLES[variant];
useEffect(() => {
if (!show) return;
const timer = setTimeout(() => {
onDone?.();
}, 2500);
return () => clearTimeout(timer);
}, [show, onDone]);
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ y: -40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -40, opacity: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="fixed top-4 left-1/2 z-[9999] -translate-x-1/2"
>
<div
className={`flex items-center gap-2 rounded-full border px-4 py-2 shadow-lg backdrop-blur-sm ${style.bg}`}
>
{style.icon}
<span className={`text-sm font-medium ${style.text}`}>{message}</span>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useCallback } from "react";
import { VacationStatus, VacationType } from "@planarchy/shared"; import { VacationStatus, VacationType } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { VacationModal } from "./VacationModal.js"; import { VacationModal } from "./VacationModal.js";
@@ -11,6 +11,7 @@ import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js"; import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS, VACATION_TYPE_BADGE } from "~/lib/status-styles.js"; import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS, VACATION_TYPE_BADGE } from "~/lib/status-styles.js";
import { SuccessToast } from "~/components/ui/SuccessToast.js";
type VacationStatusFilter = VacationStatus | "ALL"; type VacationStatusFilter = VacationStatus | "ALL";
type VacationTypeFilter = VacationType | "ALL"; type VacationTypeFilter = VacationType | "ALL";
@@ -25,6 +26,12 @@ export function VacationClient() {
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchRejectReason, setBatchRejectReason] = useState(""); const [batchRejectReason, setBatchRejectReason] = useState("");
const [showBatchRejectInput, setShowBatchRejectInput] = useState(false); const [showBatchRejectInput, setShowBatchRejectInput] = useState(false);
const [toast, setToast] = useState<{ show: boolean; message: string; variant: "success" | "warning" }>({
show: false,
message: "",
variant: "success",
});
const clearToast = useCallback(() => setToast((t) => ({ ...t, show: false })), []);
const { data: vacations, isLoading, error: vacationError, refetch } = trpc.vacation.list.useQuery( const { data: vacations, isLoading, error: vacationError, refetch } = trpc.vacation.list.useQuery(
{ {
@@ -61,6 +68,7 @@ export function VacationClient() {
const batchApproveMutation = trpc.vacation.batchApprove.useMutation({ const batchApproveMutation = trpc.vacation.batchApprove.useMutation({
onSuccess: async () => { onSuccess: async () => {
setSelected(new Set()); setSelected(new Set());
setToast({ show: true, message: "Vacations approved", variant: "success" });
await invalidateAll(); await invalidateAll();
}, },
}); });
@@ -69,6 +77,7 @@ export function VacationClient() {
setSelected(new Set()); setSelected(new Set());
setShowBatchRejectInput(false); setShowBatchRejectInput(false);
setBatchRejectReason(""); setBatchRejectReason("");
setToast({ show: true, message: "Vacations rejected", variant: "warning" });
await invalidateAll(); await invalidateAll();
}, },
}); });
@@ -122,6 +131,7 @@ export function VacationClient() {
return ( return (
<div className="p-6 max-w-5xl mx-auto space-y-6"> <div className="p-6 max-w-5xl mx-auto space-y-6">
<SuccessToast show={toast.show} message={toast.message} variant={toast.variant} onDone={clearToast} />
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
+2 -1
View File
@@ -30,8 +30,9 @@ export function Button({
return ( return (
<button <button
className={clsx( className={clsx(
"inline-flex items-center justify-center font-medium rounded-lg transition-colors", "inline-flex items-center justify-center font-medium rounded-lg transition-all duration-75",
"focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2", "focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2",
"active:scale-[0.97]",
"disabled:opacity-50 disabled:cursor-not-allowed", "disabled:opacity-50 disabled:cursor-not-allowed",
variantClasses[variant], variantClasses[variant],
sizeClasses[size], sizeClasses[size],