feat: add sidebar collapse/expand, theme-toggle, theme-aware colors
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect, Suspense, createContext, useContext } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
LayoutGridIcon,
|
||||
@@ -9,12 +9,21 @@ import {
|
||||
InboxIcon,
|
||||
ClockIcon,
|
||||
SettingsIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftIcon,
|
||||
} from "lucide-react";
|
||||
import { getTickets, getQueues } from "@/lib/api";
|
||||
import type { Queue } from "@/lib/types";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SidebarCollapsedContext = createContext(false);
|
||||
|
||||
function useSidebarCollapsed() {
|
||||
return useContext(SidebarCollapsedContext);
|
||||
}
|
||||
|
||||
interface ViewCounts {
|
||||
all: number;
|
||||
my: number;
|
||||
@@ -22,6 +31,46 @@ interface ViewCounts {
|
||||
recent: number;
|
||||
}
|
||||
|
||||
function SidebarNavItem({
|
||||
href,
|
||||
icon: Icon,
|
||||
label,
|
||||
count,
|
||||
active,
|
||||
}: {
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
count?: number;
|
||||
active: boolean;
|
||||
}) {
|
||||
const collapsed = useSidebarCollapsed();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={collapsed ? label : undefined}
|
||||
className={cn(
|
||||
"flex items-center px-2 py-1.5 rounded-md text-[13px] transition-all duration-150 mb-0.5",
|
||||
collapsed ? "justify-center w-full" : "justify-between",
|
||||
active
|
||||
? "bg-accent text-foreground font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent font-normal"
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex items-center", collapsed ? "" : "gap-2.5")}>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && label}
|
||||
</span>
|
||||
{!collapsed && count !== undefined && count > 0 && (
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarNav() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -64,6 +113,8 @@ function SidebarNav() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const collapsed = useSidebarCollapsed();
|
||||
|
||||
const views = [
|
||||
{
|
||||
label: "All tickets",
|
||||
@@ -101,64 +152,44 @@ function SidebarNav() {
|
||||
<>
|
||||
<div className="mb-4">
|
||||
{views.map((view) => {
|
||||
const Icon = view.icon;
|
||||
const active =
|
||||
pathname === "/" &&
|
||||
(view.param ? currentView === view.param : !currentView);
|
||||
return (
|
||||
<Link
|
||||
<SidebarNavItem
|
||||
key={view.label}
|
||||
href={view.href}
|
||||
className={cn(
|
||||
"flex items-center justify-between px-2 py-1.5 rounded-md text-[13px] transition-colors mb-0.5",
|
||||
active
|
||||
? "bg-[rgba(255,255,255,0.05)] text-[#f7f8f8] font-medium"
|
||||
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[rgba(255,255,255,0.03)] font-normal"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{view.label}
|
||||
</span>
|
||||
{view.count > 0 && (
|
||||
<span className="text-xs tabular-nums text-[#8a8f98]">
|
||||
{view.count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
icon={view.icon}
|
||||
label={view.label}
|
||||
count={view.count}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{queues.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1.5 text-[11px] font-semibold text-[#8a8f98] uppercase tracking-wider">
|
||||
Queues
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="px-2 py-1.5 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Queues
|
||||
</div>
|
||||
)}
|
||||
{queues.map((queue) => {
|
||||
const active =
|
||||
pathname === "/" && searchParams.get("queue") === queue.id;
|
||||
const QueueIcon = () => (
|
||||
<span className="w-2 h-2 rounded-full bg-muted-foreground flex-shrink-0" />
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
<SidebarNavItem
|
||||
key={queue.id}
|
||||
href={`/?queue=${queue.id}`}
|
||||
className={cn(
|
||||
"flex items-center justify-between px-2 py-1.5 rounded-md text-[13px] transition-colors",
|
||||
active
|
||||
? "bg-[rgba(255,255,255,0.05)] text-[#f7f8f8] font-medium"
|
||||
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[rgba(255,255,255,0.03)] font-normal"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[#8a8f98] flex-shrink-0" />
|
||||
{queue.name}
|
||||
</span>
|
||||
{queue.count > 0 && (
|
||||
<span className="text-xs tabular-nums text-[#8a8f98]">
|
||||
{queue.count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
icon={QueueIcon}
|
||||
label={queue.name}
|
||||
count={queue.count}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -169,26 +200,36 @@ function SidebarNav() {
|
||||
|
||||
function SidebarBottom() {
|
||||
const pathname = usePathname();
|
||||
const collapsed = useSidebarCollapsed();
|
||||
|
||||
return (
|
||||
<div className="border-t border-[rgba(255,255,255,0.05)] p-2">
|
||||
<Link
|
||||
<div className="border-t border-border p-2">
|
||||
<SidebarNavItem
|
||||
href="/admin"
|
||||
icon={SettingsIcon}
|
||||
label="Admin"
|
||||
active={pathname === "/admin"}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-2 py-1.5 rounded-md text-[13px] transition-colors",
|
||||
pathname === "/admin"
|
||||
? "bg-[rgba(255,255,255,0.05)] text-[#f7f8f8] font-medium"
|
||||
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[rgba(255,255,255,0.03)] font-normal"
|
||||
"flex items-center mt-0.5 px-2 py-1.5",
|
||||
collapsed ? "justify-center" : "gap-2"
|
||||
)}
|
||||
title={collapsed ? "User" : undefined}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 flex-shrink-0" />
|
||||
Admin
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 mt-0.5">
|
||||
<div className="w-5 h-5 rounded-full bg-[#5e6ad2] flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[#f7f8f8] text-[10px] font-semibold">U</span>
|
||||
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-primary-foreground text-[10px] font-semibold">
|
||||
U
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[13px] text-[#8a8f98] truncate">User</span>
|
||||
{!collapsed && (
|
||||
<span className="text-[13px] text-muted-foreground truncate">
|
||||
User
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("flex", collapsed ? "justify-center mt-1" : "mt-1")}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -196,6 +237,7 @@ function SidebarBottom() {
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
@@ -216,50 +258,73 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-60 flex-shrink-0 flex flex-col bg-[#0f1011] border-r border-[rgba(255,255,255,0.05)]">
|
||||
{/* Brand */}
|
||||
<div className="h-11 flex items-center px-3 border-b border-[rgba(255,255,255,0.05)]">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-md bg-[#5e6ad2] flex items-center justify-center">
|
||||
<span className="text-[#f7f8f8] text-[11px] font-semibold">
|
||||
T
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-semibold text-[#f7f8f8] text-sm tracking-tight">
|
||||
Tessera
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 overflow-y-auto py-2 px-2">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-1.5 px-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-7 bg-[#191a1b] rounded-md animate-pulse"
|
||||
/>
|
||||
))}
|
||||
<SidebarCollapsedContext.Provider value={sidebarCollapsed}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"flex-shrink-0 flex flex-col bg-sidebar border-r border-border transition-all duration-150",
|
||||
sidebarCollapsed ? "w-[60px]" : "w-60"
|
||||
)}
|
||||
>
|
||||
{/* Brand */}
|
||||
<div className="h-11 flex items-center px-3 border-b border-border">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-md bg-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground text-[11px] font-semibold">
|
||||
T
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SidebarNav />
|
||||
</Suspense>
|
||||
</nav>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="font-semibold text-foreground text-sm tracking-tight">
|
||||
Tessera
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Bottom */}
|
||||
<SidebarBottom />
|
||||
</aside>
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 overflow-y-auto py-2 px-2">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-1.5 px-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-7 bg-muted rounded-md animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SidebarNav />
|
||||
</Suspense>
|
||||
</nav>
|
||||
|
||||
{/* Main */}
|
||||
<main className="flex-1 overflow-y-auto">{children}</main>
|
||||
{/* Bottom */}
|
||||
<SidebarBottom />
|
||||
</aside>
|
||||
|
||||
{/* Command Palette */}
|
||||
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
|
||||
</div>
|
||||
{/* Main */}
|
||||
<main className="flex-1 overflow-y-auto">{children}</main>
|
||||
|
||||
{/* Command Palette */}
|
||||
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
|
||||
</div>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="fixed bottom-4 left-0 z-40 w-6 h-6 flex items-center justify-center rounded-r-md bg-sidebar border border-border border-l-0 text-muted-foreground hover:text-foreground transition-all duration-150"
|
||||
style={{ left: sidebarCollapsed ? 60 : 240 }}
|
||||
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<PanelLeftIcon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<PanelLeftCloseIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</SidebarCollapsedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user