Improve dashboard sidebar layout
This commit is contained in:
@@ -36,7 +36,6 @@
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@fontsource-variable/playfair-display": "5.2.8",
|
||||
"@radix-ui/react-alert-dialog": "1.1.16",
|
||||
"@radix-ui/react-avatar": "1.1.12",
|
||||
"@radix-ui/react-checkbox": "1.3.4",
|
||||
|
||||
@@ -46,7 +46,11 @@ export function ActiveTimerWidget({
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (running) {
|
||||
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();
|
||||
intervalRef.current = setInterval(tick, 1000);
|
||||
}
|
||||
@@ -73,7 +77,10 @@ export function ActiveTimerWidget({
|
||||
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 });
|
||||
} else {
|
||||
toast.success(message);
|
||||
@@ -96,12 +103,19 @@ export function ActiveTimerWidget({
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
aria-label={compact ? "Stop timer" : undefined}
|
||||
onClick={() => clockOut.mutate({})}
|
||||
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")} />
|
||||
{!compact && (clockOut.isPending ? "Stopping…" : "Stop")}
|
||||
<Square data-icon={compact ? undefined : "inline-start"} />
|
||||
{compact ? (
|
||||
<span className="sr-only">Stop timer</span>
|
||||
) : clockOut.isPending ? (
|
||||
"Stopping…"
|
||||
) : (
|
||||
"Stop"
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -133,18 +147,18 @@ export function ActiveTimerWidget({
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
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" />
|
||||
<span className="absolute top-1 right-1 flex h-2 w-2">
|
||||
<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 w-2 rounded-full" />
|
||||
<Clock className="text-primary size-5" />
|
||||
<span className="absolute top-1 right-1 flex size-2">
|
||||
<span className="bg-primary absolute inline-flex size-full animate-ping rounded-full opacity-75" />
|
||||
<span className="bg-primary relative inline-flex size-2 rounded-full" />
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
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">
|
||||
{description}
|
||||
@@ -169,10 +183,17 @@ export function ActiveTimerWidget({
|
||||
</Link>
|
||||
</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">
|
||||
<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>
|
||||
</Button>
|
||||
{renderStopButton()}
|
||||
@@ -185,58 +206,41 @@ export function ActiveTimerWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardContent className="flex flex-col gap-3 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" />
|
||||
<Card className="border-primary/30 bg-primary/5 shadow-none">
|
||||
<CardContent className="flex flex-col gap-2.5 p-3">
|
||||
<Link
|
||||
href="/dashboard/time-clock"
|
||||
className="focus-visible:ring-ring flex items-center justify-between gap-3 rounded-md outline-hidden focus-visible:ring-2"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<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" />
|
||||
</span>
|
||||
<span className="text-sm font-medium">Timer running</span>
|
||||
</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
|
||||
href={`/dashboard/invoices/${running.invoice!.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{invoiceLabel}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>No invoice selected — open time clock to assign</>
|
||||
)}
|
||||
{" · "}
|
||||
<Link href="/dashboard/time-clock" className="text-primary hover:underline">
|
||||
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">
|
||||
<span className="text-primary shrink-0 font-mono text-sm font-bold tabular-nums">
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</span>
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Button variant="outline" size="sm" asChild className="h-8 w-full">
|
||||
<Link href="/dashboard/time-clock">
|
||||
<Clock className="mr-1 h-3.5 w-3.5" />
|
||||
Open
|
||||
</Link>
|
||||
</Button>
|
||||
{renderStopButton("w-full")}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<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">
|
||||
<Clock data-icon="inline-start" />
|
||||
Open
|
||||
</Link>
|
||||
</Button>
|
||||
{renderStopButton("flex-1")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -41,12 +41,6 @@ const geistSans = localFont({
|
||||
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({
|
||||
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
|
||||
variable: "--font-geist-mono",
|
||||
@@ -61,7 +55,7 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
lang="en"
|
||||
data-color-mode="system"
|
||||
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
|
||||
className={`${geistSans.variable} ${geistMono.variable}`}
|
||||
>
|
||||
<head>
|
||||
<AppearanceInitScript />
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import { hasBusinessBrandAsset } from "~/lib/business-branding";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
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"} />;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@ import { cn } from "~/lib/utils";
|
||||
import { Menu } from "lucide-react";
|
||||
import { DashboardBrand } from "~/components/branding/dashboard-brand";
|
||||
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 { OnboardingGuard } from "~/components/layout/onboarding-guard";
|
||||
|
||||
@@ -40,10 +45,10 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-background h-10 w-10 shadow-sm"
|
||||
className="bg-background size-10 shadow-sm"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
<Menu />
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
@@ -51,10 +56,12 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
<DashboardBrand />
|
||||
<ActiveTimerWidget compact />
|
||||
</div>
|
||||
<SheetContent side="left" className="w-72 p-0">
|
||||
<div className="sr-only">
|
||||
<h2 id="mobile-nav-title">Navigation Menu</h2>
|
||||
</div>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="w-80 max-w-[90vw] gap-0 p-0"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<SheetTitle className="sr-only">Navigation menu</SheetTitle>
|
||||
<Sidebar mobile onClose={() => setIsMobileOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -64,7 +71,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
"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 ? (
|
||||
|
||||
@@ -13,29 +13,52 @@ const SidebarContext = React.createContext<SidebarContextType | 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 }) {
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const saved = localStorage.getItem("sidebar-collapsed");
|
||||
return saved ? (JSON.parse(saved) as boolean) : false;
|
||||
});
|
||||
const isCollapsed = React.useSyncExternalStore(
|
||||
subscribeToSidebar,
|
||||
getSidebarSnapshot,
|
||||
getServerSidebarSnapshot,
|
||||
);
|
||||
|
||||
const toggleCollapse = React.useCallback(() => {
|
||||
setIsCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
localStorage.setItem("sidebar-collapsed", JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
saveSidebarState(!isCollapsed);
|
||||
}, [isCollapsed]);
|
||||
|
||||
const expand = React.useCallback(() => {
|
||||
setIsCollapsed(false);
|
||||
localStorage.setItem("sidebar-collapsed", JSON.stringify(false));
|
||||
saveSidebarState(false);
|
||||
}, []);
|
||||
|
||||
const collapse = React.useCallback(() => {
|
||||
setIsCollapsed(true);
|
||||
localStorage.setItem("sidebar-collapsed", JSON.stringify(true));
|
||||
saveSidebarState(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,279 +1,358 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import {
|
||||
ChevronsUpDown,
|
||||
LogOut,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
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 { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||
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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} 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 { useDashboardUser } from "~/components/layout/dashboard-user-context";
|
||||
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
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 {
|
||||
mobile?: boolean;
|
||||
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 { data: session, isPending } = useAuthSession();
|
||||
const { isAdmin } = useDashboardUser();
|
||||
const { isCollapsed, toggleCollapse } = useSidebar();
|
||||
const navSections = getNavigationForUser(isAdmin);
|
||||
|
||||
// If mobile, always expanded
|
||||
const collapsed = mobile ? false : isCollapsed;
|
||||
|
||||
const SidebarContent = (
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div>
|
||||
{/* Header / Logo */}
|
||||
<div
|
||||
const sidebarContent = (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header
|
||||
className={cn(
|
||||
"mb-2 flex h-14 items-center px-4",
|
||||
collapsed ? "justify-center px-2" : "justify-between",
|
||||
"border-border/70 flex h-20 shrink-0 items-center border-b",
|
||||
collapsed ? "justify-center px-2" : "px-4",
|
||||
)}
|
||||
>
|
||||
{!collapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<DashboardBrand />
|
||||
</div>
|
||||
)}
|
||||
{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(
|
||||
"mt-4 flex flex-col gap-6 px-2",
|
||||
collapsed && "items-center",
|
||||
)}
|
||||
>
|
||||
{navSections.map((section) => (
|
||||
<div key={section.title}>
|
||||
{!collapsed && (
|
||||
<div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{section.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
{section.links.map((link) => {
|
||||
const Icon = link.icon;
|
||||
const isActive = isNavLinkActive(pathname, link.href);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<TooltipProvider key={link.href} delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={link.href}
|
||||
data-active={isActive ? "true" : undefined}
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="font-medium"
|
||||
>
|
||||
{link.name}
|
||||
</TooltipContent>
|
||||
</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>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Footer / User */}
|
||||
<div className="mt-auto space-y-2 p-2">
|
||||
{!mobile && (
|
||||
<div
|
||||
<Link
|
||||
href="/dashboard"
|
||||
onClick={mobile ? onClose : undefined}
|
||||
aria-label="Beenvoice dashboard"
|
||||
className={cn(
|
||||
"flex",
|
||||
collapsed ? "justify-center" : "justify-end 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",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<DashboardBrand compact={collapsed} />
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<ActiveTimerWidget collapsed={collapsed} />
|
||||
<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>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 border-t pt-4",
|
||||
collapsed ? "flex flex-col items-center gap-2" : "px-2",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3",
|
||||
collapsed ? "justify-center" : "px-2",
|
||||
)}
|
||||
>
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
{!collapsed && (
|
||||
<div className="flex-1 space-y-1">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-2 w-24" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : session?.user ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-start p-0 hover:bg-transparent",
|
||||
collapsed && "justify-center",
|
||||
)}
|
||||
{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"
|
||||
>
|
||||
{/* FIXED: Changed div to span to prevent hydration error */}
|
||||
<span
|
||||
{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 items-center gap-3",
|
||||
collapsed ? "justify-center" : "w-full",
|
||||
"flex flex-col gap-1",
|
||||
collapsed && "items-center",
|
||||
)}
|
||||
>
|
||||
<Avatar className="border-border h-9 w-9 border">
|
||||
{section.links.map((link) => (
|
||||
<SidebarLink
|
||||
key={link.href}
|
||||
{...link}
|
||||
active={isNavLinkActive(pathname, link.href)}
|
||||
collapsed={collapsed}
|
||||
mobile={mobile}
|
||||
onClose={onClose}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{!mobile ? (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 px-3 pb-3",
|
||||
collapsed && "flex justify-center",
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
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(
|
||||
"text-muted-foreground h-10 rounded-xl",
|
||||
collapsed
|
||||
? "mx-auto size-11"
|
||||
: "w-full justify-start gap-3 px-3",
|
||||
)}
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen /> : <PanelLeftClose />}
|
||||
{!collapsed ? <span>Collapse sidebar</span> : null}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<footer className="border-border/70 flex shrink-0 flex-col gap-2 border-t p-3">
|
||||
<ActiveTimerWidget collapsed={collapsed} />
|
||||
|
||||
{isPending ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 items-center gap-3 px-2",
|
||||
collapsed && "justify-center px-0",
|
||||
)}
|
||||
>
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
{!collapsed ? (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-2.5 w-28" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : session?.user ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={collapsed ? "icon" : "default"}
|
||||
aria-label={collapsed ? "Open account menu" : undefined}
|
||||
className={cn(
|
||||
"h-auto min-h-12 rounded-xl",
|
||||
collapsed
|
||||
? "mx-auto size-11 p-0"
|
||||
: "w-full justify-start gap-3 px-2 py-1.5",
|
||||
)}
|
||||
>
|
||||
<Avatar className="border-border size-9 shrink-0 border">
|
||||
<AvatarImage
|
||||
src={getGravatarUrl(session.user.email)}
|
||||
alt={session.user.name ?? "User"}
|
||||
alt=""
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{session.user.name?.[0] ?? "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!collapsed && (
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{session.user.name}
|
||||
{!collapsed ? (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{session.user.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{session.user.email}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{session.user.email}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="end"
|
||||
className="bg-background/80 border-border/50 w-56 backdrop-blur-xl"
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{session.user.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-none">
|
||||
{session.user.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await authClient.signOut();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="text-red-600 focus:bg-red-100/50 focus:text-red-600 dark:focus:bg-red-900/20"
|
||||
<ChevronsUpDown className="text-muted-foreground" />
|
||||
</>
|
||||
) : null}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side={mobile ? "top" : "right"}
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="border-border w-60 border"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<DropdownMenuLabel className="flex flex-col gap-1">
|
||||
<span className="truncate">{session.user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs font-normal">
|
||||
{session.user.email}
|
||||
</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
await authClient.signOut();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
>
|
||||
<LogOut />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
if (mobile) {
|
||||
return <div className="bg-background h-full">{SidebarContent}</div>;
|
||||
return <div className="bg-background h-full">{sidebarContent}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
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",
|
||||
isCollapsed ? "w-16" : "w-64",
|
||||
"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-20" : "w-72",
|
||||
)}
|
||||
>
|
||||
{SidebarContent}
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--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%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
@@ -100,7 +101,7 @@
|
||||
--color-ring: hsl(var(--ring));
|
||||
|
||||
--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;
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
Reference in New Issue
Block a user