Improve dashboard sidebar layout

This commit is contained in:
2026-08-18 00:44:48 -04:00
parent 57a985bcd5
commit f0b1a7355e
9 changed files with 420 additions and 330 deletions
-1
View File
@@ -36,7 +36,6 @@
"@dnd-kit/modifiers": "9.0.0", "@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0", "@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2", "@dnd-kit/utilities": "3.2.2",
"@fontsource-variable/playfair-display": "5.2.8",
"@radix-ui/react-alert-dialog": "1.1.16", "@radix-ui/react-alert-dialog": "1.1.16",
"@radix-ui/react-avatar": "1.1.12", "@radix-ui/react-avatar": "1.1.12",
"@radix-ui/react-checkbox": "1.3.4", "@radix-ui/react-checkbox": "1.3.4",
@@ -46,7 +46,11 @@ export function ActiveTimerWidget({
if (intervalRef.current) clearInterval(intervalRef.current); if (intervalRef.current) clearInterval(intervalRef.current);
if (running) { if (running) {
const tick = () => const tick = () =>
setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000)); setElapsed(
Math.floor(
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
),
);
tick(); tick();
intervalRef.current = setInterval(tick, 1000); intervalRef.current = setInterval(tick, 1000);
} }
@@ -73,7 +77,10 @@ export function ActiveTimerWidget({
window.location.assign(`/dashboard/invoices/${data.invoice!.id}`), window.location.assign(`/dashboard/invoices/${data.invoice!.id}`),
}, },
}); });
} else if (data.outcome === "saved_no_invoice" || data.outcome === "saved_no_client") { } else if (
data.outcome === "saved_no_invoice" ||
data.outcome === "saved_no_client"
) {
toast.warning("Time saved", { description: message }); toast.warning("Time saved", { description: message });
} else { } else {
toast.success(message); toast.success(message);
@@ -96,12 +103,19 @@ export function ActiveTimerWidget({
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
aria-label={compact ? "Stop timer" : undefined}
onClick={() => clockOut.mutate({})} onClick={() => clockOut.mutate({})}
disabled={clockOut.isPending} disabled={clockOut.isPending}
className={cn(compact && "h-8 px-2", className)} className={cn(compact && "size-8 p-0", className)}
> >
<Square className={cn("h-3.5 w-3.5", !compact && "mr-1.5")} /> <Square data-icon={compact ? undefined : "inline-start"} />
{!compact && (clockOut.isPending ? "Stopping…" : "Stop")} {compact ? (
<span className="sr-only">Stop timer</span>
) : clockOut.isPending ? (
"Stopping…"
) : (
"Stop"
)}
</Button> </Button>
); );
@@ -133,18 +147,18 @@ export function ActiveTimerWidget({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link <Link
href="/dashboard/time-clock" href="/dashboard/time-clock"
className="border-primary/30 bg-primary/5 relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors hover:bg-primary/10" className="border-primary/30 bg-primary/5 hover:bg-primary/10 relative flex size-10 items-center justify-center rounded-xl border transition-colors"
> >
<Clock className="text-primary h-5 w-5" /> <Clock className="text-primary size-5" />
<span className="absolute top-1 right-1 flex h-2 w-2"> <span className="absolute top-1 right-1 flex size-2">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" /> <span className="bg-primary absolute inline-flex size-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full" /> <span className="bg-primary relative inline-flex size-2 rounded-full" />
</span> </span>
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent <TooltipContent
side="right" side="right"
className="bg-popover text-popover-foreground border-border max-w-56 space-y-2 border p-3 text-sm [&>svg]:bg-popover [&>svg]:fill-popover" className="bg-popover text-popover-foreground border-border [&>svg]:bg-popover [&>svg]:fill-popover max-w-56 space-y-2 border p-3 text-sm"
> >
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{description} {description}
@@ -169,10 +183,17 @@ export function ActiveTimerWidget({
</Link> </Link>
</p> </p>
) : ( ) : (
<p className="text-muted-foreground text-xs">No invoice selected</p> <p className="text-muted-foreground text-xs">
No invoice selected
</p>
)} )}
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<Button variant="outline" size="sm" asChild className="h-8 flex-1"> <Button
variant="outline"
size="sm"
asChild
className="h-8 flex-1"
>
<Link href="/dashboard/time-clock">Open</Link> <Link href="/dashboard/time-clock">Open</Link>
</Button> </Button>
{renderStopButton()} {renderStopButton()}
@@ -185,58 +206,41 @@ export function ActiveTimerWidget({
} }
return ( return (
<Card className="border-primary/30 bg-primary/5"> <Card className="border-primary/30 bg-primary/5 shadow-none">
<CardContent className="flex flex-col gap-3 p-3"> <CardContent className="flex flex-col gap-2.5 p-3">
<div className="flex items-start gap-2">
<span className="relative mt-1 flex h-2.5 w-2.5 flex-shrink-0">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug font-medium">
{description}
{running.client && (
<span className="text-muted-foreground font-normal">
{" "}
· {running.client.name}
</span>
)}
</p>
<p className="text-muted-foreground mt-1 text-xs leading-snug">
{invoiceLabel ? (
<>
Billing to{" "}
<Link <Link
href={`/dashboard/invoices/${running.invoice!.id}`} href="/dashboard/time-clock"
className="text-primary hover:underline" className="focus-visible:ring-ring flex items-center justify-between gap-3 rounded-md outline-hidden focus-visible:ring-2"
> >
{invoiceLabel} <span className="flex min-w-0 items-center gap-2">
</Link> <span className="relative flex size-2.5 shrink-0">
</> <span className="bg-primary absolute inline-flex size-full animate-ping rounded-full opacity-75" />
) : ( <span className="bg-primary relative inline-flex size-2.5 rounded-full" />
<>No invoice selected open time clock to assign</> </span>
)} <span className="text-sm font-medium">Timer running</span>
{" · "} </span>
<Link href="/dashboard/time-clock" className="text-primary hover:underline"> <span className="text-primary shrink-0 font-mono text-sm font-bold tabular-nums">
Time clock
</Link>
</p>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<span className="text-primary text-center font-mono text-xl font-bold tabular-nums">
{formatElapsedSeconds(elapsed)} {formatElapsedSeconds(elapsed)}
</span> </span>
<div className="flex w-full flex-col gap-1.5"> </Link>
<Button variant="outline" size="sm" asChild className="h-8 w-full">
<p
className="text-muted-foreground truncate text-xs"
title={description}
>
{description}
{running.client ? ` · ${running.client.name}` : ""}
{invoiceLabel ? ` · ${invoiceLabel}` : ""}
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild className="flex-1">
<Link href="/dashboard/time-clock"> <Link href="/dashboard/time-clock">
<Clock className="mr-1 h-3.5 w-3.5" /> <Clock data-icon="inline-start" />
Open Open
</Link> </Link>
</Button> </Button>
{renderStopButton("w-full")} {renderStopButton("flex-1")}
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+1 -7
View File
@@ -41,12 +41,6 @@ const geistSans = localFont({
display: "swap", display: "swap",
}); });
const playfair = localFont({
src: "../../node_modules/@fontsource-variable/playfair-display/files/playfair-display-latin-wght-normal.woff2",
variable: "--font-playfair",
display: "swap",
});
const geistMono = localFont({ const geistMono = localFont({
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf", src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
variable: "--font-geist-mono", variable: "--font-geist-mono",
@@ -61,7 +55,7 @@ export default function RootLayout({
suppressHydrationWarning suppressHydrationWarning
lang="en" lang="en"
data-color-mode="system" data-color-mode="system"
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`} className={`${geistSans.variable} ${geistMono.variable}`}
> >
<head> <head>
<AppearanceInitScript /> <AppearanceInitScript />
@@ -1,21 +1,7 @@
"use client"; "use client";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { Logo } from "~/components/branding/logo"; import { Logo } from "~/components/branding/logo";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
import { api } from "~/trpc/react";
export function DashboardBrand({ compact = false }: { compact?: boolean }) { export function DashboardBrand({ compact = false }: { compact?: boolean }) {
const { data: business } = api.businesses.getDefault.useQuery();
if (business && hasBusinessBrandAsset(business)) {
return (
<BusinessBrandImage
business={business}
kind={compact ? "icon" : "wordmark"}
decorative
className={compact ? "h-8 w-8" : "h-8 w-36"}
/>
);
}
return <Logo size={compact ? "icon" : "sm"} />; return <Logo size={compact ? "icon" : "sm"} />;
} }
@@ -11,7 +11,12 @@ import { cn } from "~/lib/utils";
import { Menu } from "lucide-react"; import { Menu } from "lucide-react";
import { DashboardBrand } from "~/components/branding/dashboard-brand"; import { DashboardBrand } from "~/components/branding/dashboard-brand";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; import {
Sheet,
SheetContent,
SheetTitle,
SheetTrigger,
} from "~/components/ui/sheet";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget"; import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
import { OnboardingGuard } from "~/components/layout/onboarding-guard"; import { OnboardingGuard } from "~/components/layout/onboarding-guard";
@@ -40,10 +45,10 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="bg-background h-10 w-10 shadow-sm" className="bg-background size-10 shadow-sm"
suppressHydrationWarning suppressHydrationWarning
> >
<Menu className="h-5 w-5" /> <Menu />
<span className="sr-only">Toggle menu</span> <span className="sr-only">Toggle menu</span>
</Button> </Button>
</SheetTrigger> </SheetTrigger>
@@ -51,10 +56,12 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<DashboardBrand /> <DashboardBrand />
<ActiveTimerWidget compact /> <ActiveTimerWidget compact />
</div> </div>
<SheetContent side="left" className="w-72 p-0"> <SheetContent
<div className="sr-only"> side="left"
<h2 id="mobile-nav-title">Navigation Menu</h2> className="w-80 max-w-[90vw] gap-0 p-0"
</div> aria-describedby={undefined}
>
<SheetTitle className="sr-only">Navigation menu</SheetTitle>
<Sidebar mobile onClose={() => setIsMobileOpen(false)} /> <Sidebar mobile onClose={() => setIsMobileOpen(false)} />
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -64,7 +71,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
suppressHydrationWarning suppressHydrationWarning
className={cn( className={cn(
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0", "min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0",
!isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"), !isOnboarding && (isCollapsed ? "md:ml-20" : "md:ml-72"),
)} )}
> >
{isOnboarding ? ( {isOnboarding ? (
@@ -13,29 +13,52 @@ const SidebarContext = React.createContext<SidebarContextType | undefined>(
undefined, undefined,
); );
const SIDEBAR_STORAGE_KEY = "sidebar-collapsed";
const SIDEBAR_CHANGE_EVENT = "beenvoice:sidebar-change";
function getSidebarSnapshot() {
return localStorage.getItem(SIDEBAR_STORAGE_KEY) === "true";
}
function getServerSidebarSnapshot() {
return false;
}
function subscribeToSidebar(callback: () => void) {
const handleStorage = (event: StorageEvent) => {
if (event.key === SIDEBAR_STORAGE_KEY) callback();
};
window.addEventListener("storage", handleStorage);
window.addEventListener(SIDEBAR_CHANGE_EVENT, callback);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(SIDEBAR_CHANGE_EVENT, callback);
};
}
function saveSidebarState(collapsed: boolean) {
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(collapsed));
window.dispatchEvent(new Event(SIDEBAR_CHANGE_EVENT));
}
export function SidebarProvider({ children }: { children: React.ReactNode }) { export function SidebarProvider({ children }: { children: React.ReactNode }) {
const [isCollapsed, setIsCollapsed] = React.useState(() => { const isCollapsed = React.useSyncExternalStore(
if (typeof window === "undefined") return false; subscribeToSidebar,
const saved = localStorage.getItem("sidebar-collapsed"); getSidebarSnapshot,
return saved ? (JSON.parse(saved) as boolean) : false; getServerSidebarSnapshot,
}); );
const toggleCollapse = React.useCallback(() => { const toggleCollapse = React.useCallback(() => {
setIsCollapsed((prev) => { saveSidebarState(!isCollapsed);
const next = !prev; }, [isCollapsed]);
localStorage.setItem("sidebar-collapsed", JSON.stringify(next));
return next;
});
}, []);
const expand = React.useCallback(() => { const expand = React.useCallback(() => {
setIsCollapsed(false); saveSidebarState(false);
localStorage.setItem("sidebar-collapsed", JSON.stringify(false));
}, []); }, []);
const collapse = React.useCallback(() => { const collapse = React.useCallback(() => {
setIsCollapsed(true); saveSidebarState(true);
localStorage.setItem("sidebar-collapsed", JSON.stringify(true));
}, []); }, []);
return ( return (
+248 -169
View File
@@ -1,220 +1,298 @@
"use client"; "use client";
import type { ComponentType } from "react";
import {
ChevronsUpDown,
LogOut,
PanelLeftClose,
PanelLeftOpen,
Plus,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { authClient } from "~/lib/auth-client";
import { Skeleton } from "~/components/ui/skeleton"; import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
import { Button } from "~/components/ui/button";
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
import { useSidebar } from "./sidebar-provider";
import { cn } from "~/lib/utils";
import { DashboardBrand } from "~/components/branding/dashboard-brand"; import { DashboardBrand } from "~/components/branding/dashboard-brand";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Separator } from "~/components/ui/separator";
import { Skeleton } from "~/components/ui/skeleton";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "~/components/ui/tooltip"; } from "~/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { getGravatarUrl } from "~/lib/gravatar";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { useAuthSession } from "~/hooks/use-auth-session"; import { useAuthSession } from "~/hooks/use-auth-session";
import { useDashboardUser } from "~/components/layout/dashboard-user-context"; import { authClient } from "~/lib/auth-client";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget"; import { getGravatarUrl } from "~/lib/gravatar";
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
import { cn } from "~/lib/utils";
import { useDashboardUser } from "./dashboard-user-context";
import { useSidebar } from "./sidebar-provider";
interface SidebarProps { interface SidebarProps {
mobile?: boolean; mobile?: boolean;
onClose?: () => void; onClose?: () => void;
} }
export function Sidebar({ mobile, onClose }: SidebarProps) { interface SidebarLinkProps {
name: string;
href: string;
icon: ComponentType<{ className?: string }>;
active: boolean;
collapsed: boolean;
mobile: boolean;
onClose?: () => void;
}
function SidebarLink({
name,
href,
icon: Icon,
active,
collapsed,
mobile,
onClose,
}: SidebarLinkProps) {
const link = (
<Link
href={href}
aria-current={active ? "page" : undefined}
onClick={mobile ? onClose : undefined}
className={cn(
"group focus-visible:ring-ring relative flex h-10 items-center rounded-xl text-sm font-medium outline-hidden transition-colors focus-visible:ring-2",
collapsed ? "w-11 justify-center" : "gap-3 px-3",
active
? "bg-accent text-accent-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<Icon className="size-4.5 shrink-0" />
{!collapsed ? (
<>
<span className="min-w-0 flex-1 truncate">{name}</span>
{active ? (
<span className="bg-primary size-1.5 shrink-0 rounded-full" />
) : null}
</>
) : null}
</Link>
);
if (!collapsed) return link;
return (
<Tooltip>
<TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{name}
</TooltipContent>
</Tooltip>
);
}
export function Sidebar({ mobile = false, onClose }: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const { data: session, isPending } = useAuthSession(); const { data: session, isPending } = useAuthSession();
const { isAdmin } = useDashboardUser(); const { isAdmin } = useDashboardUser();
const { isCollapsed, toggleCollapse } = useSidebar(); const { isCollapsed, toggleCollapse } = useSidebar();
const navSections = getNavigationForUser(isAdmin); const navSections = getNavigationForUser(isAdmin);
// If mobile, always expanded
const collapsed = mobile ? false : isCollapsed; const collapsed = mobile ? false : isCollapsed;
const SidebarContent = ( const sidebarContent = (
<div className="flex h-full flex-col justify-between"> <TooltipProvider delayDuration={150}>
<div> <div className="flex h-full min-h-0 flex-col">
{/* Header / Logo */} <header
<div
className={cn( className={cn(
"mb-2 flex h-14 items-center px-4", "border-border/70 flex h-20 shrink-0 items-center border-b",
collapsed ? "justify-center px-2" : "justify-between", collapsed ? "justify-center px-2" : "px-4",
)} )}
> >
{!collapsed && ( <Link
<div className="flex items-center gap-2"> href="/dashboard"
<DashboardBrand /> onClick={mobile ? onClose : undefined}
</div> aria-label="Beenvoice dashboard"
)}
{collapsed && <DashboardBrand compact />}
{!mobile && !collapsed && (
<div className="h-8 w-8" /> // Spacer to keep alignment if needed, or just remove
)}
</div>
{/* Navigation */}
<nav
className={cn( className={cn(
"mt-4 flex flex-col gap-6 px-2", "focus-visible:ring-ring flex min-w-0 items-center outline-hidden focus-visible:ring-2",
collapsed ? "justify-center rounded-lg" : "flex-1",
)}
>
<DashboardBrand compact={collapsed} />
</Link>
</header>
<div className="flex min-h-0 flex-1 flex-col">
<nav
id="dashboard-sidebar-navigation"
aria-label="Dashboard navigation"
className="min-h-0 flex-1 overflow-y-auto px-3 py-4"
>
<div className="flex flex-col gap-5">
<div className={cn("flex", collapsed && "justify-center")}>
{collapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
size="icon"
className="size-11 rounded-xl"
>
<Link href="/dashboard/invoices/new">
<Plus />
<span className="sr-only">New invoice</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
New invoice
</TooltipContent>
</Tooltip>
) : (
<Button asChild className="w-full justify-start">
<Link
href="/dashboard/invoices/new"
onClick={mobile ? onClose : undefined}
>
<Plus data-icon="inline-start" />
New invoice
</Link>
</Button>
)}
</div>
{navSections.map((section, sectionIndex) => (
<section
key={section.title}
aria-label={collapsed ? section.title : undefined}
aria-labelledby={
collapsed ? undefined : `sidebar-section-${sectionIndex}`
}
className="flex flex-col gap-2"
>
{collapsed ? (
sectionIndex > 0 ? (
<Separator className="mx-auto w-8" />
) : null
) : (
<h2
id={`sidebar-section-${sectionIndex}`}
className="text-muted-foreground px-3 text-[0.6875rem] font-semibold tracking-[0.12em] uppercase"
>
{section.title}
</h2>
)}
<div
className={cn(
"flex flex-col gap-1",
collapsed && "items-center", collapsed && "items-center",
)} )}
> >
{navSections.map((section) => ( {section.links.map((link) => (
<div key={section.title}> <SidebarLink
{!collapsed && ( key={link.href}
<div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase"> {...link}
{section.title} active={isNavLinkActive(pathname, link.href)}
collapsed={collapsed}
mobile={mobile}
onClose={onClose}
/>
))}
</div> </div>
)} </section>
<div className="flex flex-col gap-1"> ))}
<div className="flex flex-col gap-1"> </div>
{section.links.map((link) => { </nav>
const Icon = link.icon;
const isActive = isNavLinkActive(pathname, link.href);
if (collapsed) { {!mobile ? (
return ( <div
<TooltipProvider key={link.href} delayDuration={0}> className={cn(
"shrink-0 px-3 pb-3",
collapsed && "flex justify-center",
)}
>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link <Button
href={link.href} type="button"
data-active={isActive ? "true" : undefined} variant="ghost"
size={collapsed ? "icon" : "default"}
aria-label={
collapsed ? "Expand sidebar" : "Collapse sidebar"
}
aria-expanded={!collapsed}
aria-controls="dashboard-sidebar-navigation"
onClick={toggleCollapse}
className={cn( className={cn(
"flex h-10 w-10 items-center justify-center rounded-md transition-colors", "text-muted-foreground h-10 rounded-xl",
isActive collapsed
? "bg-primary text-primary-foreground shadow-sm" ? "mx-auto size-11"
: "text-muted-foreground hover:bg-muted hover:text-foreground", : "w-full justify-start gap-3 px-3",
)} )}
> >
<Icon className="h-5 w-5" /> {collapsed ? <PanelLeftOpen /> : <PanelLeftClose />}
</Link> {!collapsed ? <span>Collapse sidebar</span> : null}
</Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent <TooltipContent side="right" sideOffset={8}>
side="right" {collapsed ? "Expand sidebar" : "Collapse sidebar"}
className="font-medium"
>
{link.name}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider>
);
}
return (
<Link
key={link.href}
href={link.href}
data-active={isActive ? "true" : undefined}
onClick={mobile ? onClose : undefined}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-4 w-4" />
{link.name}
</Link>
);
})}
</div> </div>
</div> ) : null}
</div>
))}
</nav>
</div>
{/* Footer / User */}
<div className="mt-auto space-y-2 p-2">
{!mobile && (
<div
className={cn(
"flex",
collapsed ? "justify-center" : "justify-end px-2",
)}
>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground h-8 w-8"
onClick={toggleCollapse}
>
{collapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</Button>
</div>
)}
<footer className="border-border/70 flex shrink-0 flex-col gap-2 border-t p-3">
<ActiveTimerWidget collapsed={collapsed} /> <ActiveTimerWidget collapsed={collapsed} />
<div
className={cn(
"border-border/50 border-t pt-4",
collapsed ? "flex flex-col items-center gap-2" : "px-2",
)}
>
{isPending ? ( {isPending ? (
<div <div
className={cn( className={cn(
"flex items-center gap-3", "flex h-12 items-center gap-3 px-2",
collapsed ? "justify-center" : "px-2", collapsed && "justify-center px-0",
)} )}
> >
<Skeleton className="h-9 w-9 rounded-full" /> <Skeleton className="size-9 rounded-full" />
{!collapsed && ( {!collapsed ? (
<div className="flex-1 space-y-1"> <div className="flex min-w-0 flex-1 flex-col gap-1.5">
<Skeleton className="h-3 w-20" /> <Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-24" /> <Skeleton className="h-2.5 w-28" />
</div> </div>
)} ) : null}
</div> </div>
) : session?.user ? ( ) : session?.user ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size={collapsed ? "icon" : "default"}
aria-label={collapsed ? "Open account menu" : undefined}
className={cn( className={cn(
"w-full justify-start p-0 hover:bg-transparent", "h-auto min-h-12 rounded-xl",
collapsed && "justify-center", collapsed
? "mx-auto size-11 p-0"
: "w-full justify-start gap-3 px-2 py-1.5",
)} )}
> >
{/* FIXED: Changed div to span to prevent hydration error */} <Avatar className="border-border size-9 shrink-0 border">
<span
className={cn(
"flex items-center gap-3",
collapsed ? "justify-center" : "w-full",
)}
>
<Avatar className="border-border h-9 w-9 border">
<AvatarImage <AvatarImage
src={getGravatarUrl(session.user.email)} src={getGravatarUrl(session.user.email)}
alt={session.user.name ?? "User"} alt=""
/> />
<AvatarFallback> <AvatarFallback>
{session.user.name?.[0] ?? "U"} {session.user.name?.[0] ?? "U"}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
{!collapsed && ( {!collapsed ? (
<>
<span className="min-w-0 flex-1 text-left"> <span className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm font-medium"> <span className="block truncate text-sm font-medium">
{session.user.name} {session.user.name}
@@ -223,57 +301,58 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
{session.user.email} {session.user.email}
</span> </span>
</span> </span>
)} <ChevronsUpDown className="text-muted-foreground" />
</span> </>
) : null}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
side="right" side={mobile ? "top" : "right"}
align="end" align="end"
className="bg-background/80 border-border/50 w-56 backdrop-blur-xl" sideOffset={8}
sideOffset={10} className="border-border w-60 border"
> >
<DropdownMenuLabel> <DropdownMenuLabel className="flex flex-col gap-1">
<div className="flex flex-col space-y-1"> <span className="truncate">{session.user.name}</span>
<p className="text-sm leading-none font-medium"> <span className="text-muted-foreground truncate text-xs font-normal">
{session.user.name}
</p>
<p className="text-muted-foreground text-xs leading-none">
{session.user.email} {session.user.email}
</p> </span>
</div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
variant="destructive"
onClick={async () => { onClick={async () => {
await authClient.signOut(); await authClient.signOut();
window.location.href = "/"; window.location.href = "/";
}} }}
className="text-red-600 focus:bg-red-100/50 focus:text-red-600 dark:focus:bg-red-900/20"
> >
<LogOut className="mr-2 h-4 w-4" /> <LogOut />
Sign Out Sign out
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : null} ) : null}
</footer>
</div> </div>
</div> </div>
</div> </TooltipProvider>
); );
if (mobile) { if (mobile) {
return <div className="bg-background h-full">{SidebarContent}</div>; return <div className="bg-background h-full">{sidebarContent}</div>;
} }
return ( return (
<aside <aside
className={cn( className={cn(
"border-border bg-background fixed top-0 bottom-0 left-0 z-30 hidden flex-col rounded-none border-r shadow-none transition-all duration-300 ease-in-out md:flex", "border-border bg-background fixed inset-y-0 left-0 z-30 hidden border-r transition-[width] duration-200 ease-out md:block",
isCollapsed ? "w-16" : "w-64", isCollapsed ? "w-20" : "w-72",
)} )}
> >
{SidebarContent} {sidebarContent}
</aside> </aside>
); );
} }
+3 -2
View File
@@ -4,7 +4,8 @@
@layer base { @layer base {
:root { :root {
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; --app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif; --app-font-heading: var(--font-geist-sans), ui-sans-serif, system-ui,
sans-serif;
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 240 10% 3.9%; --foreground: 240 10% 3.9%;
@@ -100,7 +101,7 @@
--color-ring: hsl(var(--ring)); --color-ring: hsl(var(--ring));
--font-sans: var(--app-font-sans), ui-sans-serif, system-ui, sans-serif; --font-sans: var(--app-font-sans), ui-sans-serif, system-ui, sans-serif;
--font-heading: var(--app-font-heading), ui-serif, Georgia, serif; --font-heading: var(--app-font-heading), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, monospace; --font-mono: var(--font-geist-mono), ui-monospace, monospace;
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
-3
View File
@@ -86,7 +86,6 @@
"@dnd-kit/modifiers": "9.0.0", "@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0", "@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2", "@dnd-kit/utilities": "3.2.2",
"@fontsource-variable/playfair-display": "5.2.8",
"@radix-ui/react-alert-dialog": "1.1.16", "@radix-ui/react-alert-dialog": "1.1.16",
"@radix-ui/react-avatar": "1.1.12", "@radix-ui/react-avatar": "1.1.12",
"@radix-ui/react-checkbox": "1.3.4", "@radix-ui/react-checkbox": "1.3.4",
@@ -664,8 +663,6 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@fontsource-variable/playfair-display": ["@fontsource-variable/playfair-display@5.2.8", "", {}, "sha512-ZzVIXPOrL85yyOvZYoBzUszIJM+xKkHqni4IYn2CVLaGQQdJR8sBeC8yFNgjxSJ7ludTwta8qpULeOFuk5X75A=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],