feat: add sidebar collapse/expand, theme-toggle, theme-aware colors
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user