Add 'apps/web/' from commit '1e7174fa604b11e7c3983cd8ad01c596f6e77e96'
git-subtree-dir: apps/web git-subtree-mainline:068a51b46bgit-subtree-split:1e7174fa60
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user