Add 'apps/web/' from commit '1e7174fa604b11e7c3983cd8ad01c596f6e77e96'

git-subtree-dir: apps/web
git-subtree-mainline: 068a51b46b
git-subtree-split: 1e7174fa60
This commit is contained in:
2026-08-16 21:42:59 -04:00
350 changed files with 62192 additions and 0 deletions
@@ -0,0 +1,29 @@
"use client";
// Sets data-color-mode / .dark on <html> from localStorage before paint, to
// avoid a flash of the wrong theme. Rendered only during SSR (typeof window
// check) and returns null on the client, so the <script> element never
// enters the tree React reconciles during hydration — React 19 otherwise
// warns "Encountered a script tag while rendering React component" for any
// <script> it walks while hydrating, even one from next/script. Same fix
// next-themes ships for its inline ThemeScript (shadcn-ui/ui#10238).
const APPEARANCE_INIT_SOURCE = `
try {
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
var colorMode = stored.colorMode || "system";
var root = document.documentElement;
root.dataset.colorMode = colorMode;
if (colorMode === "dark") root.classList.add("dark");
} catch {}
`;
export function AppearanceInitScript() {
if (typeof window !== "undefined") return null;
return (
<script
id="appearance-init"
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: APPEARANCE_INIT_SOURCE }}
/>
);
}
@@ -0,0 +1,9 @@
/** Grid + animated blob backdrop shared by the app shell and marketing pages. */
export function BrandBackground() {
return (
<div className="brand-background pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]" />
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/40 blur-3xl dark:bg-neutral-500/30" />
</div>
);
}
@@ -0,0 +1,59 @@
import { cn } from "~/lib/utils";
/** Vertical rhythm for dashboard pages — use with shell `gap-5`. */
export const dashboardGapClass = "gap-5 md:gap-6";
/** Standard grid gap for dashboard cards and sections. */
export const dashboardGridClass =
"grid gap-5 md:gap-6";
/** Summary stat cards (2-up mobile, 4-up desktop). */
export const dashboardStatGridClass =
"grid grid-cols-2 gap-4 sm:grid-cols-4";
export function DashboardPage({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"page-enter mx-auto flex w-full max-w-7xl flex-col",
dashboardGapClass,
className,
)}
>
{children}
</div>
);
}
export function DashboardGrid({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn(dashboardGridClass, className)}>{children}</div>
);
}
export function DashboardCardTitle({
children,
icon: Icon,
}: {
children: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
}) {
return (
<span className="flex items-center gap-2">
{Icon ? <Icon className="text-muted-foreground h-4 w-4" /> : null}
{children}
</span>
);
}
@@ -0,0 +1,88 @@
"use client";
import * as React from "react";
import { usePathname } from "next/navigation";
import { Sidebar } from "~/components/layout/sidebar";
import {
SidebarProvider,
useSidebar,
} from "~/components/layout/sidebar-provider";
import { cn } from "~/lib/utils";
import { Menu } from "lucide-react";
import { Logo } from "~/components/branding/logo";
import { Button } from "~/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
import { OnboardingGuard } from "~/components/layout/onboarding-guard";
function DashboardContent({ children }: { children: React.ReactNode }) {
const { isCollapsed } = useSidebar();
const pathname = usePathname();
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
const isOnboarding = pathname === "/dashboard/onboarding";
return (
<div className="bg-dashboard relative flex min-h-screen">
{!isOnboarding && (
<div className="hidden md:block">
<Sidebar />
</div>
)}
<div
className={cn(
"dashboard-mobile-header bg-background/80 border-border fixed top-0 right-0 left-0 z-50 flex min-h-16 items-center border-b px-3 backdrop-blur-md sm:px-4 md:hidden",
isOnboarding && "hidden",
)}
>
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
className="bg-background h-10 w-10 shadow-sm"
suppressHydrationWarning
>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
<Logo size="sm" className="shrink-0" />
<ActiveTimerWidget compact />
</div>
<SheetContent side="left" className="w-72 p-0">
<div className="sr-only">
<h2 id="mobile-nav-title">Navigation Menu</h2>
</div>
<Sidebar mobile onClose={() => setIsMobileOpen(false)} />
</SheetContent>
</Sheet>
</div>
<main
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 ? (
<OnboardingGuard>{children}</OnboardingGuard>
) : (
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
<OnboardingGuard>{children}</OnboardingGuard>
</div>
)}
</main>
</div>
);
}
export function DashboardShell({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider>
<DashboardContent>{children}</DashboardContent>
</SidebarProvider>
);
}
@@ -0,0 +1,29 @@
"use client";
import { createContext, useContext } from "react";
interface DashboardUserContextValue {
isAdmin: boolean;
needsOnboarding: boolean;
}
const DashboardUserContext = createContext<DashboardUserContextValue>({
isAdmin: false,
needsOnboarding: false,
});
export function DashboardUserProvider({
isAdmin,
needsOnboarding,
children,
}: DashboardUserContextValue & { children: React.ReactNode }) {
return (
<DashboardUserContext.Provider value={{ isAdmin, needsOnboarding }}>
{children}
</DashboardUserContext.Provider>
);
}
export function useDashboardUser() {
return useContext(DashboardUserContext);
}
@@ -0,0 +1,47 @@
"use client";
import React from "react";
import { cn } from "~/lib/utils";
import { Card, CardContent } from "~/components/ui/card";
import { useSidebar } from "~/components/layout/sidebar-provider";
interface FloatingActionBarProps {
leftContent?: React.ReactNode;
children: React.ReactNode;
className?: string;
}
export function FloatingActionBar({
leftContent,
children,
className,
}: FloatingActionBarProps) {
const { isCollapsed } = useSidebar();
return (
<div
className={cn(
"pb-safe-area-inset-bottom fixed right-0 bottom-4 left-0 z-50 transition-all duration-300 ease-in-out",
isCollapsed ? "md:left-16" : "md:left-64",
"animate-slide-in-bottom",
className,
)}
>
<div className="w-full px-4 transition-transform duration-300">
<Card className="hover-lift bg-card border-border border shadow-lg">
<CardContent className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm:p-4">
{leftContent && (
<div className="text-card-foreground animate-fade-in flex flex-1 items-center gap-3">
{leftContent}
</div>
)}
<div className="animate-fade-in animate-delay-100 flex items-center gap-2 sm:gap-3">
{children}
</div>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
"use client";
import { cn } from "~/lib/utils";
export function MotionBackground() {
return (
<div className="bg-background pointer-events-none fixed inset-0 -z-50 overflow-hidden">
<div
className={cn(
"absolute inset-[-50%] h-[200%] w-[200%]",
"bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]",
"from-[oklch(var(--primary)/0.15)] via-transparent to-transparent",
"animate-subtle-spin opacity-100",
)}
/>
<div
className={cn(
"absolute inset-[-50%] h-[200%] w-[200%]",
"bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]",
"from-[oklch(var(--accent)/0.15)] via-transparent to-transparent",
"animate-subtle-wave opacity-100",
)}
/>
<div className="absolute inset-0 bg-[url('/noise.svg')] opacity-[0.02] mix-blend-overlay" />
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { authClient } from "~/lib/auth-client";
import Link from "next/link";
import { useState } from "react";
import { Logo } from "~/components/branding/logo";
import { SidebarTrigger } from "~/components/navigation/sidebar-trigger";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { useAuthSession } from "~/hooks/use-auth-session";
import { useRouter } from "next/navigation";
interface NavbarProps {
allowRegistration?: boolean;
}
export function Navbar({ allowRegistration = true }: NavbarProps) {
const { data: session, isPending } = useAuthSession();
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
const router = useRouter();
// Get current open invoice for quick access
// const { data: currentInvoice } = api.invoices.getCurrentOpen.useQuery();
return (
<header className="bg-navbar border-navbar-border text-navbar-foreground fixed top-0 right-0 left-0 z-30 border-b">
<div className="flex h-14 items-center justify-between px-4 md:h-16 md:px-8">
<div className="flex items-center gap-4 md:gap-6">
<SidebarTrigger
isOpen={isMobileNavOpen}
onToggle={() => setIsMobileNavOpen(!isMobileNavOpen)}
/>
<Link href="/dashboard" className="flex items-center gap-2">
<Logo size="md" />
</Link>
</div>
<div className="flex items-center gap-2 md:gap-4">
{isPending ? (
<>
<Skeleton className="bg-muted/20 hidden h-5 w-20 sm:inline" />
<Skeleton className="bg-muted/20 h-8 w-16" />
</>
) : session?.user ? (
<>
<span className="text-muted-foreground hidden text-xs font-medium sm:inline md:text-sm">
{session.user.name ?? session.user.email}
</span>
<Button
variant="outline"
size="sm"
onClick={async () => {
await authClient.signOut();
router.push("/");
}}
className="text-xs md:text-sm"
>
Sign Out
</Button>
</>
) : (
<>
<Link href="/auth/signin">
<Button
variant="ghost"
size="sm"
className="text-xs md:text-sm"
>
Sign In
</Button>
</Link>
{allowRegistration && (
<Link href="/auth/register">
<Button
size="sm"
variant="default"
className="text-xs font-medium md:text-sm"
>
Register
</Button>
</Link>
)}
</>
)}
</div>
</div>
</header>
);
}
@@ -0,0 +1,24 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useDashboardUser } from "./dashboard-user-context";
export function OnboardingGuard({ children }: { children: React.ReactNode }) {
const { needsOnboarding } = useDashboardUser();
const pathname = usePathname();
const router = useRouter();
const onOnboardingPage = pathname === "/dashboard/onboarding";
useEffect(() => {
if (needsOnboarding && !onOnboardingPage) {
router.replace("/dashboard/onboarding");
}
}, [needsOnboarding, onOnboardingPage, router]);
if (needsOnboarding && !onOnboardingPage) {
return null;
}
return children;
}
@@ -0,0 +1,115 @@
import React from "react";
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
import { cn } from "~/lib/utils";
interface PageHeaderProps {
title: string;
description?: string;
children?: React.ReactNode; // For action buttons or other header content
className?: string;
variant?: "default" | "gradient" | "large" | "large-gradient";
titleClassName?: string;
}
export function PageHeader({
title,
description,
children,
className = "",
variant = "default",
titleClassName,
}: PageHeaderProps) {
const getTitleClasses = () => {
const baseClasses = "font-bold";
switch (variant) {
case "gradient":
return `${baseClasses} text-3xl text-foreground`;
case "large":
return `${baseClasses} text-4xl text-foreground`;
case "large-gradient":
return `${baseClasses} text-4xl text-foreground`;
default:
return `${baseClasses} text-3xl text-foreground`;
}
};
const getDescriptionSpacing = () => {
return variant === "large" || variant === "large-gradient"
? "mt-2"
: "mt-1";
};
return (
<div className={cn("animate-fade-in-down", className)}>
{variant === "large-gradient" || variant === "gradient" ? (
<div className="platform-header-surface bg-card text-card-foreground relative overflow-hidden rounded-xl border shadow-sm">
<div className="platform-header-gradient from-primary/5 pointer-events-none absolute inset-0 bg-gradient-to-br via-transparent to-transparent" />
<div className="platform-header-content relative p-6">
<DashboardBreadcrumbs className="mb-4" />
{/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<h1 className={titleClassName ?? getTitleClasses()}>{title}</h1>
{description && (
<p
className={`text-muted-foreground ${getDescriptionSpacing()} text-lg`}
>
{description}
</p>
)}
</div>
{children && (
<div className="flex w-full flex-shrink-0 gap-2 sm:w-auto sm:gap-3">
{children}
</div>
)}
</div>
</div>
</div>
) : (
<>
<DashboardBreadcrumbs className="mb-2 sm:mb-4" />
{/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="animate-fade-in-up space-y-1">
<h1 className={titleClassName ?? getTitleClasses()}>{title}</h1>
{description && (
<p
className={`animate-fade-in-up animate-delay-100 text-muted-foreground ${getDescriptionSpacing()} text-lg`}
>
{description}
</p>
)}
</div>
{children && (
<div className="animate-slide-in-right animate-delay-200 flex w-full flex-shrink-0 gap-2 sm:w-auto sm:gap-3">
{children}
</div>
)}
</div>
</>
)}
</div>
);
}
// Convenience wrapper for dashboard page with larger gradient title
export function DashboardPageHeader({
title,
description,
children,
className = "",
}: Omit<PageHeaderProps, "variant">) {
return (
<PageHeader
title={title}
description={description}
variant="gradient"
className={cn("mb-0", className)}
titleClassName="font-heading text-2xl font-semibold tracking-tight sm:text-3xl"
>
{children}
</PageHeader>
);
}
@@ -0,0 +1,139 @@
import * as React from "react";
import { cn } from "~/lib/utils";
interface PageLayoutProps {
children: React.ReactNode;
className?: string;
}
export function PageLayout({ children, className }: PageLayoutProps) {
return <div className={cn("min-h-screen", className)}>{children}</div>;
}
interface PageContentProps {
children: React.ReactNode;
className?: string;
spacing?: "default" | "compact" | "large";
}
export function PageContent({
children,
className,
spacing = "default",
}: PageContentProps) {
const spacingClasses = {
default: "space-y-8",
compact: "space-y-4",
large: "space-y-12",
};
return (
<div className={cn(spacingClasses[spacing], className)}>{children}</div>
);
}
interface PageSectionProps {
children: React.ReactNode;
className?: string;
title?: string;
description?: string;
actions?: React.ReactNode;
}
export function PageSection({
children,
className,
title,
description,
actions,
}: PageSectionProps) {
return (
<section className={cn("space-y-4", className)}>
{(title ?? description ?? actions) && (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
{title && (
<h2 className="text-foreground text-xl font-semibold">{title}</h2>
)}
{description && (
<p className="text-muted-foreground mt-1 text-sm">
{description}
</p>
)}
</div>
{actions && <div className="flex flex-shrink-0 gap-3">{actions}</div>}
</div>
)}
{children}
</section>
);
}
interface PageGridProps {
children: React.ReactNode;
className?: string;
columns?: 1 | 2 | 3 | 4;
gap?: "default" | "compact" | "large";
}
export function PageGrid({
children,
className,
columns = 3,
gap = "default",
}: PageGridProps) {
const columnClasses = {
1: "grid-cols-1",
2: "grid-cols-1 md:grid-cols-2",
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
};
const gapClasses = {
default: "gap-4",
compact: "gap-2",
large: "gap-6",
};
return (
<div
className={cn("grid", columnClasses[columns], gapClasses[gap], className)}
>
{children}
</div>
);
}
// Empty state component for consistent empty states across pages
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
export function EmptyState({
icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div className={cn("py-12 text-center", className)}>
{icon && (
<div className="bg-muted mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl p-3 [&_svg]:text-muted-foreground">
{icon}
</div>
)}
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
{description && (
<p className="text-muted-foreground mx-auto mb-4 max-w-sm text-sm">
{description}
</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
);
}
@@ -0,0 +1,75 @@
"use client";
import * as React from "react";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "~/components/ui/tabs";
import { dashboardGapClass, dashboardGridClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
/** Vertical rhythm inside tab panels — matches dashboard page sections. */
export const pageTabsPanelClass = dashboardGapClass;
/** Grid for stacked cards inside a tab panel. */
export const pageTabsGridClass = dashboardGridClass;
type PageTabsProps = React.ComponentPropsWithoutRef<typeof Tabs>;
export function PageTabs({ className, ...props }: PageTabsProps) {
return (
<Tabs
className={cn("flex flex-col", pageTabsPanelClass, className)}
{...props}
/>
);
}
type PageTabsListProps = React.ComponentPropsWithoutRef<typeof TabsList>;
export function PageTabsList({ className, ...props }: PageTabsListProps) {
return (
<div className="-mx-1 overflow-x-auto px-1 pb-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<TabsList
className={cn(
"bg-muted/50 border-border/60 inline-flex h-10 w-max min-w-full gap-0.5 rounded-xl border p-1 sm:min-w-0",
className,
)}
{...props}
/>
</div>
);
}
export function PageTabsTrigger({
className,
...props
}: React.ComponentPropsWithoutRef<typeof TabsTrigger>) {
return (
<TabsTrigger
className={cn(
"data-[state=active]:bg-background h-8 rounded-lg px-3.5 text-sm data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
);
}
export function PageTabsContent({
className,
...props
}: React.ComponentPropsWithoutRef<typeof TabsContent>) {
return (
<TabsContent
className={cn(
"mt-0 flex flex-col focus-visible:ring-0 focus-visible:outline-none",
pageTabsPanelClass,
className,
)}
{...props}
/>
);
}
@@ -0,0 +1,111 @@
import * as React from "react";
import { Card, CardContent } from "~/components/ui/card";
import { cn } from "~/lib/utils";
import type { LucideIcon } from "lucide-react";
interface QuickActionCardProps {
title: string;
description?: string;
icon: LucideIcon;
variant?: "default" | "success" | "info" | "warning" | "purple";
className?: string;
onClick?: () => void;
children?: React.ReactNode;
}
const variantStyles = {
default: {
icon: "text-foreground",
background: "bg-muted/50",
hoverBackground: "group-hover:bg-muted/70",
},
success: {
icon: "text-status-success",
background: "bg-status-success-muted",
hoverBackground: "group-hover:bg-status-success-muted/70",
},
info: {
icon: "text-status-info",
background: "bg-status-info-muted",
hoverBackground: "group-hover:bg-status-info-muted/70",
},
warning: {
icon: "text-status-warning",
background: "bg-status-warning-muted",
hoverBackground: "group-hover:bg-status-warning-muted/70",
},
purple: {
icon: "text-primary",
background: "bg-secondary",
hoverBackground: "group-hover:bg-secondary/80",
},
};
export function QuickActionCard({
title,
description,
icon: Icon,
variant = "default",
className,
onClick,
children,
}: QuickActionCardProps) {
const styles = variantStyles[variant];
const content = (
<CardContent className="p-6 text-center">
<div
className={cn(
"mx-auto mb-3 flex h-12 w-12 items-center justify-center transition-colors",
styles.background,
styles.hoverBackground,
)}
>
<Icon className={cn("h-6 w-6", styles.icon)} />
</div>
<h3 className="font-semibold">{title}</h3>
{description && (
<p className="text-muted-foreground mt-1 text-sm">{description}</p>
)}
</CardContent>
);
if (children) {
return (
<Card
className={cn(
"group cursor-pointer border-0 shadow-md transition-all hover:scale-[1.02] hover:shadow-lg",
className,
)}
>
{children}
</Card>
);
}
return (
<Card
className={cn(
"group cursor-pointer border-0 shadow-md transition-all hover:scale-[1.02] hover:shadow-lg",
className,
)}
onClick={onClick}
>
{content}
</Card>
);
}
export function QuickActionCardSkeleton() {
return (
<Card className="bg-card border-border border">
<CardContent className="p-6">
<div className="animate-pulse">
<div className="bg-muted mx-auto mb-3 h-12 w-12"></div>
<div className="bg-muted mx-auto mb-2 h-4 w-2/3 rounded"></div>
<div className="bg-muted mx-auto h-3 w-1/2 rounded"></div>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,56 @@
"use client";
import * as React from "react";
interface SidebarContextType {
isCollapsed: boolean;
toggleCollapse: () => void;
expand: () => void;
collapse: () => void;
}
const SidebarContext = React.createContext<SidebarContextType | undefined>(
undefined,
);
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 toggleCollapse = React.useCallback(() => {
setIsCollapsed((prev) => {
const next = !prev;
localStorage.setItem("sidebar-collapsed", JSON.stringify(next));
return next;
});
}, []);
const expand = React.useCallback(() => {
setIsCollapsed(false);
localStorage.setItem("sidebar-collapsed", JSON.stringify(false));
}, []);
const collapse = React.useCallback(() => {
setIsCollapsed(true);
localStorage.setItem("sidebar-collapsed", JSON.stringify(true));
}, []);
return (
<SidebarContext.Provider
value={{ isCollapsed, toggleCollapse, expand, collapse }}
>
{children}
</SidebarContext.Provider>
);
}
export function useSidebar() {
const context = React.useContext(SidebarContext);
if (context === undefined) {
throw new Error("useSidebar must be used within a SidebarProvider");
}
return context;
}
+279
View File
@@ -0,0 +1,279 @@
"use client";
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 { Logo } from "~/components/branding/logo";
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";
interface SidebarProps {
mobile?: boolean;
onClose?: () => void;
}
export function Sidebar({ mobile, 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
className={cn(
"mb-2 flex h-14 items-center px-4",
collapsed ? "justify-center px-2" : "justify-between",
)}
>
{!collapsed && (
<div className="flex items-center gap-2">
<Logo size="sm" />
</div>
)}
{collapsed && <Logo size="icon" />}
{!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
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>
)}
<ActiveTimerWidget collapsed={collapsed} />
<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",
)}
>
{/* FIXED: Changed div to span to prevent hydration error */}
<span
className={cn(
"flex items-center gap-3",
collapsed ? "justify-center" : "w-full",
)}
>
<Avatar className="border-border h-9 w-9 border">
<AvatarImage
src={getGravatarUrl(session.user.email)}
alt={session.user.name ?? "User"}
/>
<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}
</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"
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
</div>
);
if (mobile) {
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",
)}
>
{SidebarContent}
</aside>
);
}