feat: add sidebar collapse/expand, theme-toggle, theme-aware colors

This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-07 22:34:26 +02:00
parent 10005799fb
commit b05eb8b2d4

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect, Suspense } from "react"; import { useState, useEffect, Suspense, createContext, useContext } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { import {
LayoutGridIcon, LayoutGridIcon,
@@ -9,12 +9,21 @@ import {
InboxIcon, InboxIcon,
ClockIcon, ClockIcon,
SettingsIcon, SettingsIcon,
PanelLeftCloseIcon,
PanelLeftIcon,
} from "lucide-react"; } from "lucide-react";
import { getTickets, getQueues } from "@/lib/api"; import { getTickets, getQueues } from "@/lib/api";
import type { Queue } from "@/lib/types"; import type { Queue } from "@/lib/types";
import { CommandPalette } from "@/components/command-palette"; import { CommandPalette } from "@/components/command-palette";
import { ThemeToggle } from "@/components/theme-toggle";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const SidebarCollapsedContext = createContext(false);
function useSidebarCollapsed() {
return useContext(SidebarCollapsedContext);
}
interface ViewCounts { interface ViewCounts {
all: number; all: number;
my: number; my: number;
@@ -22,6 +31,46 @@ interface ViewCounts {
recent: number; 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() { function SidebarNav() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -64,6 +113,8 @@ function SidebarNav() {
}); });
}, []); }, []);
const collapsed = useSidebarCollapsed();
const views = [ const views = [
{ {
label: "All tickets", label: "All tickets",
@@ -101,64 +152,44 @@ function SidebarNav() {
<> <>
<div className="mb-4"> <div className="mb-4">
{views.map((view) => { {views.map((view) => {
const Icon = view.icon;
const active = const active =
pathname === "/" && pathname === "/" &&
(view.param ? currentView === view.param : !currentView); (view.param ? currentView === view.param : !currentView);
return ( return (
<Link <SidebarNavItem
key={view.label} key={view.label}
href={view.href} href={view.href}
className={cn( icon={view.icon}
"flex items-center justify-between px-2 py-1.5 rounded-md text-[13px] transition-colors mb-0.5", label={view.label}
active count={view.count}
? "bg-[rgba(255,255,255,0.05)] text-[#f7f8f8] font-medium" active={active}
: "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>
); );
})} })}
</div> </div>
{queues.length > 0 && ( {queues.length > 0 && (
<div> <div>
<div className="px-2 py-1.5 text-[11px] font-semibold text-[#8a8f98] uppercase tracking-wider"> {!collapsed && (
<div className="px-2 py-1.5 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
Queues Queues
</div> </div>
)}
{queues.map((queue) => { {queues.map((queue) => {
const active = const active =
pathname === "/" && searchParams.get("queue") === queue.id; pathname === "/" && searchParams.get("queue") === queue.id;
const QueueIcon = () => (
<span className="w-2 h-2 rounded-full bg-muted-foreground flex-shrink-0" />
);
return ( return (
<Link <SidebarNavItem
key={queue.id} key={queue.id}
href={`/?queue=${queue.id}`} href={`/?queue=${queue.id}`}
className={cn( icon={QueueIcon}
"flex items-center justify-between px-2 py-1.5 rounded-md text-[13px] transition-colors", label={queue.name}
active count={queue.count}
? "bg-[rgba(255,255,255,0.05)] text-[#f7f8f8] font-medium" active={active}
: "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>
); );
})} })}
</div> </div>
@@ -169,26 +200,36 @@ function SidebarNav() {
function SidebarBottom() { function SidebarBottom() {
const pathname = usePathname(); const pathname = usePathname();
const collapsed = useSidebarCollapsed();
return ( return (
<div className="border-t border-[rgba(255,255,255,0.05)] p-2"> <div className="border-t border-border p-2">
<Link <SidebarNavItem
href="/admin" href="/admin"
icon={SettingsIcon}
label="Admin"
active={pathname === "/admin"}
/>
<div
className={cn( className={cn(
"flex items-center gap-2.5 px-2 py-1.5 rounded-md text-[13px] transition-colors", "flex items-center mt-0.5 px-2 py-1.5",
pathname === "/admin" collapsed ? "justify-center" : "gap-2"
? "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"
)} )}
title={collapsed ? "User" : undefined}
> >
<SettingsIcon className="w-4 h-4 flex-shrink-0" /> <div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center flex-shrink-0">
Admin <span className="text-primary-foreground text-[10px] font-semibold">
</Link> U
<div className="flex items-center gap-2 px-2 py-1.5 mt-0.5"> </span>
<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> </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>
</div> </div>
); );
@@ -196,6 +237,7 @@ function SidebarBottom() {
export function AppShell({ children }: { children: React.ReactNode }) { export function AppShell({ children }: { children: React.ReactNode }) {
const [commandOpen, setCommandOpen] = useState(false); const [commandOpen, setCommandOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
useEffect(() => { useEffect(() => {
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {
@@ -216,20 +258,28 @@ export function AppShell({ children }: { children: React.ReactNode }) {
}, []); }, []);
return ( return (
<SidebarCollapsedContext.Provider value={sidebarCollapsed}>
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
<aside className="w-60 flex-shrink-0 flex flex-col bg-[#0f1011] border-r border-[rgba(255,255,255,0.05)]"> <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 */} {/* Brand */}
<div className="h-11 flex items-center px-3 border-b border-[rgba(255,255,255,0.05)]"> <div className="h-11 flex items-center px-3 border-b border-border">
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2">
<div className="w-5 h-5 rounded-md bg-[#5e6ad2] flex items-center justify-center"> <div className="w-5 h-5 rounded-md bg-primary flex items-center justify-center">
<span className="text-[#f7f8f8] text-[11px] font-semibold"> <span className="text-primary-foreground text-[11px] font-semibold">
T T
</span> </span>
</div> </div>
<span className="font-semibold text-[#f7f8f8] text-sm tracking-tight"> {!sidebarCollapsed && (
<span className="font-semibold text-foreground text-sm tracking-tight">
Tessera Tessera
</span> </span>
)}
</Link> </Link>
</div> </div>
@@ -241,7 +291,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div <div
key={i} key={i}
className="h-7 bg-[#191a1b] rounded-md animate-pulse" className="h-7 bg-muted rounded-md animate-pulse"
/> />
))} ))}
</div> </div>
@@ -261,5 +311,20 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{/* Command Palette */} {/* Command Palette */}
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} /> <CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
</div> </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>
); );
} }