Unify the dashboard experience and retire the multi-theme engine so onboarding and day-to-day invoicing feel consistent and easier to maintain.

Shared layout, tabs, and sidebar timer; user onboarding and registration polish; settings danger zone and data export; chart and tRPC perf fixes; migrations for onboarding and dropped appearance columns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 03:08:22 -04:00
co-authored by Cursor
parent 6ec26a4a0d
commit c53f2e6c4d
79 changed files with 2871 additions and 3576 deletions
+6 -10
View File
@@ -2,7 +2,6 @@
import { motion } from "framer-motion";
import { brand } from "~/lib/branding";
import { useAppearance } from "~/components/providers/appearance-provider";
import { cn } from "~/lib/utils";
interface LogoProps {
@@ -25,10 +24,7 @@ function splitLogoText(logoText: string) {
}
export function Logo({ className, size = "md", animated = true }: LogoProps) {
const appearance = useAppearance();
const logoText = appearance.brandLogoText || brand.logoText;
const icon = appearance.brandIcon || brand.icon;
const [logoPrefix, logoSuffix] = splitLogoText(logoText);
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
const sizeClasses = {
sm: "text-base",
md: "text-xl",
@@ -45,7 +41,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
sizeClasses={sizeClasses}
logoPrefix={logoPrefix}
logoSuffix={logoSuffix}
icon={icon}
icon={brand.icon}
/>
);
}
@@ -67,7 +63,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
className="text-primary font-bold tracking-tight"
>
{icon}
{brand.icon}
</motion.span>
{size !== "icon" && (
<>
@@ -75,8 +71,8 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }}
className="inline-block w-1" // Reduced from w-2 to w-1 (half space)
></motion.span>
className="inline-block w-1"
/>
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -125,7 +121,7 @@ function LogoContent({
<span className="text-primary font-bold tracking-tight">{icon}</span>
{size !== "icon" && (
<>
<span className="inline-block w-1"></span>
<span className="inline-block w-1" />
<span className="text-foreground font-bold tracking-tight">
{logoPrefix}
</span>
@@ -0,0 +1,25 @@
"use client";
import type { ReactElement } from "react";
import { ResponsiveContainer } from "recharts";
import { cn } from "~/lib/utils";
interface ResponsiveChartProps {
height?: number;
className?: string;
children: ReactElement;
}
export function ResponsiveChart({
height = 256,
className,
children,
}: ResponsiveChartProps) {
return (
<div className={cn("w-full min-w-0", className)}>
<ResponsiveContainer width="100%" height={height} minWidth={0}>
{children}
</ResponsiveContainer>
</div>
);
}
+14 -9
View File
@@ -20,7 +20,9 @@ import { useEffect, useState } from "react";
import { toast } from "sonner";
import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
@@ -408,7 +410,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
(mode === "edit" && isLoadingEmailConfig)
) {
return (
<div className="space-y-6 pb-32">
<DashboardPage className="pb-32">
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
@@ -430,21 +432,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</div>
</CardContent>
</Card>
</div>
</DashboardPage>
);
}
return (
<>
<div className="space-y-6 pb-32">
<PageHeader
<DashboardPage className="pb-32">
<DashboardPageHeader
title={mode === "edit" ? "Edit Business" : "Add Business"}
description={
mode === "edit"
? "Update business information below"
: "Enter business details below to add a new business."
}
variant="gradient"
>
<Button
type="submit"
@@ -469,9 +470,13 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</>
)}
</Button>
</PageHeader>
</DashboardPageHeader>
<form id="business-form" onSubmit={handleSubmit} className="space-y-6">
<form
id="business-form"
onSubmit={handleSubmit}
className={cn("flex flex-col", dashboardGapClass)}
>
{/* Main Form Container - styled like data table */}
<div className="space-y-4">
{/* Basic Information */}
@@ -902,7 +907,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</Card>
</div>
</form>
</div>
</DashboardPage>
<FloatingActionBar
leftContent={
+14 -9
View File
@@ -19,7 +19,9 @@ import { Label } from "~/components/ui/label";
import { Skeleton } from "~/components/ui/skeleton";
import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { NumberInput } from "~/components/ui/number-input";
import { api } from "~/trpc/react";
import {
@@ -237,7 +239,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
if (mode === "edit" && isLoadingClient) {
return (
<div className="space-y-6 pb-32">
<DashboardPage className="pb-32">
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
@@ -259,21 +261,20 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</div>
</CardContent>
</Card>
</div>
</DashboardPage>
);
}
return (
<>
<div className="space-y-6 pb-32">
<PageHeader
<DashboardPage className="pb-32">
<DashboardPageHeader
title={mode === "edit" ? "Edit Client" : "Add Client"}
description={
mode === "edit"
? "Update client information below"
: "Enter client details below to add a new client."
}
variant="gradient"
>
<Button
type="submit"
@@ -298,9 +299,13 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</>
)}
</Button>
</PageHeader>
</DashboardPageHeader>
<form id="client-form" onSubmit={handleSubmit} className="space-y-6">
<form
id="client-form"
onSubmit={handleSubmit}
className={cn("flex flex-col", dashboardGapClass)}
>
{/* Main Form Container - styled like data table */}
<div className="space-y-4">
{/* Basic Information */}
@@ -508,7 +513,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</Card>
</div>
</form>
</div>
</DashboardPage>
<FloatingActionBar
leftContent={
+2 -4
View File
@@ -1,6 +1,7 @@
"use client";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { getAppUrl } from "~/lib/app-url";
interface EmailPreviewProps {
subject: string;
@@ -89,10 +90,7 @@ export function EmailPreview({
customMessage: customMessage,
userName: invoice.business?.name ?? "Your Business",
userEmail: fromEmail,
baseUrl:
typeof window !== "undefined"
? window.location.origin
: "https://beenvoice.app",
baseUrl: getAppUrl(),
})
: null;
+55 -81
View File
@@ -6,7 +6,18 @@ import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Label } from "~/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import {
DashboardPage,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { cn } from "~/lib/utils";
import {
Select,
SelectContent,
@@ -17,7 +28,6 @@ import {
import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input";
import { PageHeader } from "~/components/layout/page-header";
import { InvoiceLineItems } from "./invoice-line-items";
import { InvoiceCalendarView } from "./invoice-calendar-view";
import { EmailPreview } from "./email-preview";
@@ -62,19 +72,17 @@ interface InvoiceFormProps {
function InvoiceFormSkeleton() {
return (
<div className="space-y-6 pb-8">
<PageHeader
<DashboardPage className="pb-8">
<DashboardPageHeader
title="Loading..."
description="Loading invoice form"
variant="gradient"
/>
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />{" "}
{/* Tabs Skeleton */}
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
</div>
</div>
</DashboardPage>
);
}
@@ -462,8 +470,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
return (
<>
<div className="page-enter space-y-6 pb-8">
<PageHeader
<DashboardPage className="pb-8">
<DashboardPageHeader
title={
invoiceId !== "new"
? "Edit Invoice"
@@ -476,7 +484,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
? "Set up a draft to clock time into later"
: "Manage your invoice"
}
variant="gradient"
>
{invoiceId !== "new" && (
<Button
@@ -491,42 +498,28 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Save className="mr-2 h-4 w-4" />
{loading ? "Saving..." : "Save"}
</Button>
</PageHeader>
</DashboardPageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
{/* TAB SELECTOR: w-full, p-1, visible background */}
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1 lg:grid-cols-3">
<TabsTrigger
value="details"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Details
</TabsTrigger>
<TabsTrigger
value="items"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Items
</TabsTrigger>
<TabsTrigger
value="timesheet"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Timesheet
</TabsTrigger>
<TabsTrigger
value="preview"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm lg:hidden"
>
<div
className={cn(
dashboardGridClass,
"lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]",
)}
>
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
<PageTabsList>
<PageTabsTrigger value="details">Details</PageTabsTrigger>
<PageTabsTrigger value="items">Items</PageTabsTrigger>
<PageTabsTrigger value="timesheet">Timesheet</PageTabsTrigger>
<PageTabsTrigger value="preview" className="lg:hidden">
Preview
</TabsTrigger>
</TabsList>
</PageTabsTrigger>
</PageTabsList>
{/* DETAILS TAB */}
<TabsContent
<PageTabsContent
value="details"
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2"
className={cn(dashboardGridClass, "lg:grid-cols-2")}
>
<Card className="h-full">
<CardHeader>
@@ -770,13 +763,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
{/* ITEMS TAB */}
<TabsContent
value="items"
className="mt-6 focus-visible:outline-none"
>
<PageTabsContent value="items">
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<Card className="bg-primary/5 border-primary/20">
<CardContent className="flex items-center justify-between p-4">
@@ -826,13 +816,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
{/* TIMESHEET TAB */}
<TabsContent
value="timesheet"
className="mt-6 focus-visible:outline-none"
>
<PageTabsContent value="timesheet">
<Card className="min-h-[600px] w-full">
<CardHeader>
<CardTitle className="flex gap-2">
@@ -850,37 +837,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
<TabsContent
value="preview"
className="mt-6 focus-visible:outline-none"
>
<Tabs
<PageTabsContent value="preview">
<PageTabs
value={previewTab}
onValueChange={setPreviewTab}
className="w-full"
>
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1">
<TabsTrigger
value="pdf"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
PDF
</TabsTrigger>
<TabsTrigger
value="email"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Email
</TabsTrigger>
</TabsList>
<PageTabsList>
<PageTabsTrigger value="pdf">PDF</PageTabsTrigger>
<PageTabsTrigger value="email">Email</PageTabsTrigger>
</PageTabsList>
<TabsContent value="pdf" className="mt-6">
<PageTabsContent value="pdf">
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
</TabsContent>
</PageTabsContent>
<TabsContent value="email" className="mt-6">
<PageTabsContent value="email">
<Card>
<CardHeader>
<CardTitle className="flex gap-2">
@@ -928,10 +902,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
</PageTabsContent>
</PageTabs>
</PageTabsContent>
</PageTabs>
<aside className="hidden lg:block">
<div className="sticky top-4 space-y-4">
@@ -950,7 +924,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
</aside>
</div>
</div>
</DashboardPage>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
+59
View File
@@ -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>
);
}
+21 -28
View File
@@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { usePathname } from "next/navigation";
import { Sidebar } from "~/components/layout/sidebar";
import {
SidebarProvider,
@@ -11,23 +12,29 @@ 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 { useAppearance } from "~/components/providers/appearance-provider";
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 { sidebarStyle } = useAppearance();
const pathname = usePathname();
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
const isOnboarding = pathname === "/dashboard/onboarding";
return (
<div className="bg-dashboard relative flex min-h-screen">
{/* Desktop Sidebar */}
<div className="hidden md:block">
<Sidebar />
</div>
{!isOnboarding && (
<div className="hidden md:block">
<Sidebar />
</div>
)}
{/* Mobile Sidebar (Sheet) */}
<div className="dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden">
<div
className={cn(
"dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden",
isOnboarding && "hidden",
)}
>
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
<SheetTrigger asChild>
<Button
@@ -40,9 +47,9 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
{/* Mobile Link / Logo */}
<div className="ml-4 flex items-center gap-2">
<div className="ml-4 flex min-w-0 flex-1 items-center gap-2">
<Logo size="sm" />
<ActiveTimerWidget compact />
</div>
<SheetContent side="left" className="w-72 p-0">
<div className="sr-only">
@@ -53,29 +60,15 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
</Sheet>
</div>
{/* Main Content */}
<main
suppressHydrationWarning
className={cn(
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out",
"md:ml-0",
sidebarStyle === "floating"
? isCollapsed
? "md:ml-24"
: "md:ml-[18rem]"
: isCollapsed
? "md:ml-16"
: "md:ml-64",
"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"),
)}
>
<div className="dashboard-content-shell p-4 pt-16 md:pt-4">
<div className="mb-4 md:hidden">
{/* Mobile Breadcrumbs could go here or be part of the page */}
</div>
<div className="mb-4">
<ActiveTimerWidget />
</div>
{children}
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
<OnboardingGuard>{children}</OnboardingGuard>
</div>
</main>
</div>
@@ -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);
}
+1 -12
View File
@@ -3,15 +3,11 @@
import React from "react";
import { cn } from "~/lib/utils";
import { Card, CardContent } from "~/components/ui/card";
import { useAppearance } from "~/components/providers/appearance-provider";
import { useSidebar } from "~/components/layout/sidebar-provider";
interface FloatingActionBarProps {
/** Content to display on the left side */
leftContent?: React.ReactNode;
/** Action buttons to display on the right */
children: React.ReactNode;
/** Additional className for styling */
className?: string;
}
@@ -21,19 +17,12 @@ export function FloatingActionBar({
className,
}: FloatingActionBarProps) {
const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance();
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",
sidebarStyle === "floating"
? isCollapsed
? "md:left-24"
: "md:left-[18rem]"
: isCollapsed
? "md:left-16"
: "md:left-64",
isCollapsed ? "md:left-16" : "md:left-64",
"animate-slide-in-bottom",
className,
)}
@@ -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;
}
+5 -3
View File
@@ -1,5 +1,6 @@
import React from "react";
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
import { cn } from "~/lib/utils";
interface PageHeaderProps {
title: string;
@@ -40,7 +41,7 @@ export function PageHeader({
};
return (
<div className={`animate-fade-in-down mb-6 ${className}`}>
<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" />
@@ -104,8 +105,9 @@ export function DashboardPageHeader({
<PageHeader
title={title}
description={description}
variant="large-gradient"
className={className}
variant="gradient"
className={cn("mb-0", className)}
titleClassName="font-heading text-2xl font-semibold tracking-tight sm:text-3xl"
>
{children}
</PageHeader>
+75
View File
@@ -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}
/>
);
}
+9 -8
View File
@@ -6,7 +6,7 @@ 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 { navigationConfig, isNavLinkActive } from "~/lib/navigation";
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
import { useSidebar } from "./sidebar-provider";
import { cn } from "~/lib/utils";
import { Logo } from "~/components/branding/logo";
@@ -26,8 +26,9 @@ import {
} from "~/components/ui/dropdown-menu";
import { getGravatarUrl } from "~/lib/gravatar";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { useAppearance } from "~/components/providers/appearance-provider";
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;
@@ -37,8 +38,9 @@ interface SidebarProps {
export function Sidebar({ mobile, onClose }: SidebarProps) {
const pathname = usePathname();
const { data: session, isPending } = useAuthSession();
const { isAdmin } = useDashboardUser();
const { isCollapsed, toggleCollapse } = useSidebar();
const { sidebarStyle } = useAppearance();
const navSections = getNavigationForUser(isAdmin);
// If mobile, always expanded
const collapsed = mobile ? false : isCollapsed;
@@ -72,7 +74,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
collapsed && "items-center",
)}
>
{navigationConfig.map((section) => (
{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">
@@ -163,6 +165,8 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
</div>
)}
<ActiveTimerWidget collapsed={collapsed} />
<div
className={cn(
"border-border/50 border-t pt-4",
@@ -265,10 +269,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
return (
<aside
className={cn(
"fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex",
sidebarStyle === "floating"
? "border-border/50 bg-background/80 top-4 bottom-4 left-4 rounded-3xl border shadow-xl backdrop-blur-xl"
: "border-border bg-background top-0 bottom-0 left-0 rounded-none border-r shadow-none",
"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",
)}
>
+6 -3
View File
@@ -9,8 +9,11 @@ import {
Users,
} from "lucide-react";
import { BrowserFrame } from "~/components/marketing/browser-frame";
import { getAppHost } from "~/lib/app-url";
import { cn } from "~/lib/utils";
const appHost = getAppHost();
function MockSidebar({ active }: { active: "dashboard" | "invoices" | "time" }) {
const items = [
{ id: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard },
@@ -87,7 +90,7 @@ export function InvoicesScreenshot({ className }: { className?: string }) {
];
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard/invoices">
<BrowserFrame className={className} url={`${appHost}/dashboard/invoices`}>
<div className="flex min-h-[280px] sm:min-h-[320px]">
<MockSidebar active="invoices" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
@@ -134,7 +137,7 @@ export function InvoicesScreenshot({ className }: { className?: string }) {
export function TimeClockScreenshot({ className }: { className?: string }) {
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard/time-clock">
<BrowserFrame className={className} url={`${appHost}/dashboard/time-clock`}>
<div className="flex min-h-[260px] sm:min-h-[300px]">
<MockSidebar active="time" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
@@ -204,7 +207,7 @@ export function TimeClockScreenshot({ className }: { className?: string }) {
export function DashboardScreenshot({ className }: { className?: string }) {
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard">
<BrowserFrame className={className} url={`${appHost}/dashboard`}>
<div className="flex min-h-[260px] sm:min-h-[300px]">
<MockSidebar active="dashboard" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
+2 -1
View File
@@ -1,9 +1,10 @@
import { getAppHost } from "~/lib/app-url";
import { cn } from "~/lib/utils";
export function BrowserFrame({
children,
className,
url = "beenvoice.app/dashboard",
url = `${getAppHost()}/dashboard`,
}: {
children: React.ReactNode;
className?: string;
@@ -6,7 +6,8 @@ import { usePathname } from "next/navigation";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { useAuthSession } from "~/hooks/use-auth-session";
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
import { useDashboardUser } from "~/components/layout/dashboard-user-context";
interface SidebarTriggerProps {
isOpen: boolean;
@@ -16,6 +17,8 @@ interface SidebarTriggerProps {
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
const pathname = usePathname();
const { isPending } = useAuthSession();
const { isAdmin } = useDashboardUser();
const navSections = getNavigationForUser(isAdmin);
return (
<>
@@ -34,7 +37,7 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
<div className="bg-background border-border absolute top-full right-0 left-0 z-40 mt-1 border-t">
{/* Navigation content */}
<nav className="flex flex-col p-4">
{navigationConfig.map((section, sectionIndex) => (
{navSections.map((section, sectionIndex) => (
<div
key={section.title}
className={sectionIndex > 0 ? "mt-4" : ""}
@@ -177,7 +177,6 @@ export function AnimationPreferencesProviderSynced({
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect
performUpdate(
{
prefersReducedMotion: serverPrefs.prefersReducedMotion,
@@ -187,12 +186,9 @@ export function AnimationPreferencesProviderSynced({
);
}
serverHydratedRef.current = true;
}, [
serverPrefs,
performUpdate,
prefersReducedMotion,
animationSpeedMultiplier,
]);
// One-time hydration from server after local storage is read.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverPrefs]);
const updatePreferences = useCallback<
AnimationPreferencesContextValue["updatePreferences"]
@@ -1,232 +1,88 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isHslChannels } from "~/lib/appearance";
import type { ColorMode, ColorTheme, FontPreference, InterfaceTheme, RadiusPreference, SidebarStyle } from "~/lib/branding";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { defaultColorMode, type ColorMode } from "~/lib/appearance";
import { api } from "~/trpc/react";
import {
AppearanceContext,
applyAppearance,
applyColorMode,
defaultAppearance,
readStoredAppearance,
writeStoredAppearance,
readStoredColorMode,
writeStoredColorMode,
type AppearanceContextValue,
type AppearancePatch,
type AppearancePreferences,
} from "~/components/providers/appearance-provider";
type ServerAppearance = {
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
theme: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: AppearancePreferences["pdfTemplate"];
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
function getServerAppearancePatch(
serverAppearance: ServerAppearance,
): AppearancePatch {
return {
interfaceTheme: serverAppearance.interfaceTheme,
bodyFontPreference: serverAppearance.bodyFontPreference,
headingFontPreference: serverAppearance.headingFontPreference,
radiusPreference: serverAppearance.radiusPreference,
sidebarStyle: serverAppearance.sidebarStyle,
colorMode: serverAppearance.theme,
colorTheme: serverAppearance.colorTheme,
customColor: serverAppearance.customColor,
brandName: serverAppearance.brandName,
brandTagline: serverAppearance.brandTagline,
brandLogoText: serverAppearance.brandLogoText,
brandIcon: serverAppearance.brandIcon,
pdfTemplate: serverAppearance.pdfTemplate,
pdfAccentColor: serverAppearance.pdfAccentColor,
pdfFooterText: serverAppearance.pdfFooterText,
pdfShowLogo: serverAppearance.pdfShowLogo,
pdfShowPageNumbers: serverAppearance.pdfShowPageNumbers,
};
}
/** Dashboard appearance provider with tRPC theme sync. Must render inside TRPCReactProvider. */
/** Dashboard appearance provider with per-user color mode sync. */
export function AppearanceProviderSynced({
children,
}: {
children: React.ReactNode;
}) {
const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
const serverHydratedRef = useRef(false);
const utils = api.useUtils();
const updateMutation = api.settings.updateTheme.useMutation({
onSuccess: async () => {
await utils.settings.getTheme.invalidate();
},
const updateMutation = api.settings.updateColorMode.useMutation({
onError: () => {
const cachedAppearance = utils.settings.getTheme.getData();
const fallback = cachedAppearance
? {
...defaultAppearance,
...getServerAppearancePatch(cachedAppearance),
}
: defaultAppearance;
setAppearance(fallback);
applyAppearance(fallback);
writeStoredAppearance(fallback);
const cached = utils.settings.getColorMode.getData();
const fallback = cached?.colorMode ?? defaultColorMode;
setColorMode(fallback);
applyColorMode(fallback);
writeStoredColorMode(fallback);
},
});
const persistAppearance = useCallback(
(patch: AppearancePatch) => {
if (
patch.customColor !== undefined &&
!isHslChannels(patch.customColor)
) {
return;
}
const { data: serverColorMode } = api.settings.getColorMode.useQuery(
undefined,
{
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
},
);
updateMutation.mutate({
interfaceTheme: patch.interfaceTheme,
bodyFontPreference: patch.bodyFontPreference,
headingFontPreference: patch.headingFontPreference,
radiusPreference: patch.radiusPreference,
sidebarStyle: patch.sidebarStyle,
theme: patch.colorMode,
colorTheme: patch.colorTheme,
customColor: patch.customColor,
brandName: patch.brandName,
brandTagline: patch.brandTagline,
brandLogoText: patch.brandLogoText,
brandIcon: patch.brandIcon,
pdfTemplate: patch.pdfTemplate,
pdfAccentColor: patch.pdfAccentColor,
pdfFooterText: patch.pdfFooterText,
pdfShowLogo: patch.pdfShowLogo,
pdfShowPageNumbers: patch.pdfShowPageNumbers,
});
useEffect(() => {
const stored = readStoredColorMode();
if (stored) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(stored);
}
}, []);
useEffect(() => {
if (!serverColorMode?.colorMode) return;
if (serverHydratedRef.current) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(serverColorMode.colorMode);
serverHydratedRef.current = true;
}, [serverColorMode?.colorMode]);
useEffect(() => {
applyColorMode(colorMode);
writeStoredColorMode(colorMode);
}, [colorMode]);
const updateAppearance = useCallback(
(patch: AppearancePatch) => {
if (!patch.colorMode) return;
setColorMode(patch.colorMode);
applyColorMode(patch.colorMode);
writeStoredColorMode(patch.colorMode);
updateMutation.mutate({ colorMode: patch.colorMode });
},
[updateMutation],
);
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
useEffect(() => {
const storedAppearance = readStoredAppearance();
if (!storedAppearance) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
}, []);
useEffect(() => {
if (!serverAppearance) return;
const next = getServerAppearancePatch(serverAppearance);
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...next }));
}, [serverAppearance]);
useEffect(() => {
applyAppearance(appearance);
writeStoredAppearance(appearance);
}, [appearance]);
const updateAppearance = useCallback(
(patch: AppearancePatch) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (Object.keys(pendingDebouncedPatchRef.current).length > 0) {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
}
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
persistAppearance(patch);
},
[persistAppearance],
);
const updateAppearanceDebounced = useCallback(
(patch: AppearancePatch) => {
pendingDebouncedPatchRef.current = {
...pendingDebouncedPatchRef.current,
...patch,
};
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
debounceTimerRef.current = null;
}, 500);
},
[persistAppearance],
);
useEffect(
() => () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
pendingDebouncedPatchRef.current = {};
},
[],
);
const value = useMemo<AppearanceContextValue>(
() => ({
...appearance,
...defaultAppearance,
colorMode,
updateAppearance,
updateAppearanceDebounced,
isUpdating: updateMutation.isPending,
}),
[
appearance,
updateAppearance,
updateAppearanceDebounced,
updateMutation.isPending,
],
[colorMode, updateAppearance, updateMutation.isPending],
);
return (
+26 -166
View File
@@ -8,181 +8,53 @@ import {
useMemo,
useState,
} from "react";
import {
fallbackAppearance,
isColorMode,
isColorTheme,
isFontPreference,
isHslChannels,
isInterfaceTheme,
isPdfTemplate,
isRadiusPreference,
isSidebarStyle,
type PdfTemplate,
} from "~/lib/appearance";
import {
defaultBodyFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
brand as defaultBrand,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/branding";
import { defaultColorMode, isColorMode, type ColorMode } from "~/lib/appearance";
export type AppearancePreferences = {
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
colorMode: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
export type AppearancePatch = Partial<AppearancePreferences>;
export type AppearanceContextValue = AppearancePreferences & {
updateAppearance: (patch: AppearancePatch) => void;
updateAppearanceDebounced: (patch: AppearancePatch) => void;
isUpdating: boolean;
};
export const STORAGE_KEY = "bv.appearance";
export const defaultAppearance: AppearancePreferences = {
interfaceTheme: defaultInterfaceTheme,
bodyFontPreference: defaultBodyFontPreference,
headingFontPreference: defaultHeadingFontPreference,
radiusPreference: defaultRadiusPreference,
sidebarStyle: defaultSidebarStyle,
colorMode: fallbackAppearance.colorMode,
colorTheme: fallbackAppearance.colorTheme,
brandName: defaultBrand.name,
brandTagline: defaultBrand.tagline,
brandLogoText: defaultBrand.logoText,
brandIcon: defaultBrand.icon,
pdfTemplate: fallbackAppearance.pdfTemplate,
pdfAccentColor: fallbackAppearance.pdfAccentColor,
pdfFooterText: fallbackAppearance.pdfFooterText,
pdfShowLogo: fallbackAppearance.pdfShowLogo,
pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
colorMode: defaultColorMode,
};
export const AppearanceContext =
createContext<AppearanceContextValue | null>(null);
export function readStoredAppearance(): Partial<AppearancePreferences> | null {
export function readStoredColorMode(): ColorMode | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
interfaceTheme: isInterfaceTheme(parsed.interfaceTheme)
? parsed.interfaceTheme
: undefined,
bodyFontPreference: isFontPreference(parsed.bodyFontPreference)
? parsed.bodyFontPreference
: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
headingFontPreference: isFontPreference(parsed.headingFontPreference)
? parsed.headingFontPreference
: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
radiusPreference: isRadiusPreference(parsed.radiusPreference)
? parsed.radiusPreference
: undefined,
sidebarStyle: isSidebarStyle(parsed.sidebarStyle)
? parsed.sidebarStyle
: undefined,
colorMode: isColorMode(parsed.colorMode) ? parsed.colorMode : undefined,
colorTheme: isColorTheme(parsed.colorTheme)
? parsed.colorTheme
: undefined,
customColor: isHslChannels(parsed.customColor)
? parsed.customColor
: undefined,
brandName:
typeof parsed.brandName === "string" ? parsed.brandName : undefined,
brandTagline:
typeof parsed.brandTagline === "string"
? parsed.brandTagline
: undefined,
brandLogoText:
typeof parsed.brandLogoText === "string"
? parsed.brandLogoText
: undefined,
brandIcon:
typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined,
pdfTemplate: isPdfTemplate(parsed.pdfTemplate)
? parsed.pdfTemplate
: undefined,
pdfAccentColor:
typeof parsed.pdfAccentColor === "string"
? parsed.pdfAccentColor
: undefined,
pdfFooterText:
typeof parsed.pdfFooterText === "string"
? parsed.pdfFooterText
: undefined,
pdfShowLogo:
typeof parsed.pdfShowLogo === "boolean"
? parsed.pdfShowLogo
: undefined,
pdfShowPageNumbers:
typeof parsed.pdfShowPageNumbers === "boolean"
? parsed.pdfShowPageNumbers
: undefined,
};
const parsed = JSON.parse(raw) as { colorMode?: unknown };
return isColorMode(parsed.colorMode) ? parsed.colorMode : null;
} catch {
return null;
}
}
export function writeStoredAppearance(prefs: AppearancePreferences) {
export function writeStoredColorMode(colorMode: ColorMode) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
localStorage.setItem(STORAGE_KEY, JSON.stringify({ colorMode }));
} catch {
// Storage can be unavailable in private browsing or locked-down contexts.
}
}
export function applyAppearance(prefs: AppearancePreferences) {
export function applyColorMode(colorMode: ColorMode) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.dataset.interfaceTheme = prefs.interfaceTheme;
root.dataset.bodyFont = prefs.bodyFontPreference;
root.dataset.headingFont = prefs.headingFontPreference;
root.dataset.radius = prefs.radiusPreference;
root.dataset.sidebarStyle = prefs.sidebarStyle;
root.dataset.colorMode = prefs.colorMode;
root.dataset.colorTheme = prefs.colorTheme;
root.classList.toggle("dark", prefs.colorMode === "dark");
if (prefs.customColor) {
root.style.setProperty("--custom-primary", prefs.customColor);
} else {
root.style.removeProperty("--custom-primary");
}
root.dataset.colorMode = colorMode;
root.classList.toggle("dark", colorMode === "dark");
}
/** Local-only appearance provider for marketing and auth pages (no tRPC). */
@@ -191,48 +63,36 @@ export function AppearanceProvider({
}: {
children: React.ReactNode;
}) {
const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance);
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
useEffect(() => {
const storedAppearance = readStoredAppearance();
if (!storedAppearance) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
const stored = readStoredColorMode();
if (stored) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(stored);
}
}, []);
useEffect(() => {
applyAppearance(appearance);
writeStoredAppearance(appearance);
}, [appearance]);
applyColorMode(colorMode);
writeStoredColorMode(colorMode);
}, [colorMode]);
const updateAppearance = useCallback((patch: AppearancePatch) => {
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
}, []);
const updateAppearanceDebounced = useCallback((patch: AppearancePatch) => {
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
if (patch.colorMode) {
setColorMode(patch.colorMode);
applyColorMode(patch.colorMode);
writeStoredColorMode(patch.colorMode);
}
}, []);
const value = useMemo<AppearanceContextValue>(
() => ({
...appearance,
colorMode,
updateAppearance,
updateAppearanceDebounced,
isUpdating: false,
}),
[appearance, updateAppearance, updateAppearanceDebounced],
[colorMode, updateAppearance],
);
return (
+2 -2
View File
@@ -11,7 +11,7 @@ const Tabs = React.forwardRef<
>(({ className, ...props }, ref) => (
<TabsPrimitive.Root
ref={ref}
className={cn("flex flex-col gap-1", className)}
className={cn("flex flex-col gap-2", className)}
{...props}
/>
));
@@ -54,7 +54,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
className,
)}
{...props}