feat: add administration page and account role management

- Implemented `AdministrationContent` component for managing account roles.
- Created `AdministrationPage` to serve as the main entry point for administration tasks.
- Added PDF preview functionality with `PdfPreviewFrame` component for invoice generation.
- Introduced `InputColor` component for advanced color selection with various formats.
- Established color conversion utilities in `color-converter.ts` for handling color formats.
- Defined appearance-related schemas and types in `appearance.ts` for consistent theme management.
This commit is contained in:
2026-04-30 10:50:50 -04:00
parent ddc2b42672
commit 0e46fdafb2
87 changed files with 4562 additions and 2421 deletions
+14 -14
View File
@@ -4,20 +4,20 @@ import Script from "next/script";
import { env } from "~/env";
export function UmamiScript() {
if (process.env.NODE_ENV === "development") {
return null;
}
if (process.env.NODE_ENV === "development") {
return null;
}
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
return null;
}
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
return null;
}
return (
<Script
defer
src={env.NEXT_PUBLIC_UMAMI_SCRIPT_URL}
data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
strategy="afterInteractive"
/>
);
return (
<Script
defer
src={env.NEXT_PUBLIC_UMAMI_SCRIPT_URL}
data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
strategy="afterInteractive"
/>
);
}
@@ -64,7 +64,7 @@ export function AddressAutocomplete({
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
/>
{showSuggestions && suggestions.length > 0 && (
<Card className="bg-card border-border border absolute z-10 mt-1 max-h-60 w-full overflow-auto">
<Card className="bg-card border-border absolute z-10 mt-1 max-h-60 w-full overflow-auto border">
<ul>
{suggestions.map((s) => (
<li
+25 -10
View File
@@ -11,10 +11,24 @@ interface LogoProps {
animated?: boolean;
}
function splitLogoText(logoText: string) {
const voiceIndex = logoText.toLowerCase().indexOf("voice");
if (voiceIndex > 0) {
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
}
return [
logoText.slice(0, Math.ceil(logoText.length / 2)),
logoText.slice(Math.ceil(logoText.length / 2)),
] as const;
}
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 sizeClasses = {
sm: "text-base",
md: "text-xl",
@@ -29,7 +43,8 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
className={className}
size={size}
sizeClasses={sizeClasses}
logoText={logoText}
logoPrefix={logoPrefix}
logoSuffix={logoSuffix}
icon={icon}
/>
);
@@ -68,7 +83,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.04, duration: 0.05, ease: "easeOut" }}
className="text-foreground font-bold tracking-tight"
>
{logoText.slice(0, Math.ceil(logoText.length / 2))}
{logoPrefix}
</motion.span>
<motion.span
initial={{ opacity: 0 }}
@@ -76,7 +91,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.06, duration: 0.05, ease: "easeOut" }}
className="text-foreground/70 font-bold tracking-tight"
>
{logoText.slice(Math.ceil(logoText.length / 2))}
{logoSuffix}
</motion.span>
</>
)}
@@ -88,13 +103,15 @@ function LogoContent({
className,
size,
sizeClasses,
logoText,
logoPrefix,
logoSuffix,
icon,
}: {
className?: string;
size: "sm" | "md" | "lg" | "xl" | "icon";
sizeClasses: Record<string, string>;
logoText: string;
logoPrefix: string;
logoSuffix: string;
icon: string;
}) {
return (
@@ -105,17 +122,15 @@ function LogoContent({
className,
)}
>
<span className="text-primary font-bold tracking-tight">
{icon}
</span>
<span className="text-primary font-bold tracking-tight">{icon}</span>
{size !== "icon" && (
<>
<span className="inline-block w-1"></span>
<span className="text-foreground font-bold tracking-tight">
{logoText.slice(0, Math.ceil(logoText.length / 2))}
{logoPrefix}
</span>
<span className="text-foreground/70 font-bold tracking-tight">
{logoText.slice(Math.ceil(logoText.length / 2))}
{logoSuffix}
</span>
</>
)}
+6 -9
View File
@@ -460,7 +460,7 @@ export function CSVImportPage() {
applyGlobalClient(newClientId);
}
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-12 w-full border px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-12 w-full border px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={loadingClients}
>
<option value="">No default client (select individually)</option>
@@ -506,7 +506,7 @@ export function CSVImportPage() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="bg-primary/10 grid grid-cols-2 gap-4 p-4 md:grid-cols-4">
<div className="bg-primary/10 grid grid-cols-2 gap-4 p-4 md:grid-cols-4">
<div className="text-center">
<div className="text-primary text-2xl font-bold">
{totalFiles}
@@ -556,10 +556,7 @@ export function CSVImportPage() {
<CardContent>
<div className="space-y-4">
{files.map((fileData, index) => (
<div
key={index}
className="border-border bg-card border p-4"
>
<div key={index} className="border-border bg-card border p-4">
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<FileText className="text-primary h-5 w-5" />
@@ -619,7 +616,7 @@ export function CSVImportPage() {
onChange={(e) =>
updateFileData(index, { clientId: e.target.value })
}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={loadingClients}
>
<option value="">Select Client</option>
@@ -662,7 +659,7 @@ export function CSVImportPage() {
{/* Error Display */}
{fileData.errors.length > 0 && (
<div className="border-destructive/20 bg-destructive/10 mt-4 border p-3">
<div className="border-destructive/20 bg-destructive/10 mt-4 border p-3">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="text-destructive h-4 w-4" />
<span className="text-destructive text-sm font-medium">
@@ -772,7 +769,7 @@ export function CSVImportPage() {
{/* Preview Modal */}
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
<DialogContent className="bg-card border-border border flex max-h-[90vh] max-w-4xl flex-col">
<DialogContent className="bg-card border-border flex max-h-[90vh] max-w-4xl flex-col border">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="text-foreground flex items-center gap-2 text-xl font-bold">
<FileText className="text-primary h-5 w-5" />
+35 -42
View File
@@ -3,6 +3,7 @@
import type {
ColumnDef,
ColumnFiltersState,
RowData,
SortingState,
VisibilityState,
} from "@tanstack/react-table";
@@ -53,6 +54,14 @@ import {
} from "~/components/ui/table";
import { cn } from "~/lib/utils";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Generic names must match TanStack's declaration for module augmentation.
interface ColumnMeta<TData extends RowData, TValue> {
headerClassName?: string;
cellClassName?: string;
}
}
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
@@ -125,23 +134,9 @@ export function DataTable<TData, TValue>({
...column,
// Add a meta property to control responsive visibility
meta: {
...((
column as ColumnDef<TData, TValue> & {
meta?: { headerClassName?: string; cellClassName?: string };
}
).meta ?? {}),
headerClassName:
(
column as ColumnDef<TData, TValue> & {
meta?: { headerClassName?: string; cellClassName?: string };
}
).meta?.headerClassName ?? "",
cellClassName:
(
column as ColumnDef<TData, TValue> & {
meta?: { headerClassName?: string; cellClassName?: string };
}
).meta?.cellClassName ?? "",
...(column.meta ?? {}),
headerClassName: column.meta?.headerClassName ?? "",
cellClassName: column.meta?.cellClassName ?? "",
},
}));
}, [columns]);
@@ -369,9 +364,7 @@ export function DataTable<TData, TValue>({
className="bg-muted/50 hover:bg-muted/50"
>
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as
| { headerClassName?: string; cellClassName?: string }
| undefined;
const meta = header.column.columnDef.meta;
return (
<TableHead
key={header.id}
@@ -383,9 +376,9 @@ export function DataTable<TData, TValue>({
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
@@ -407,9 +400,7 @@ export function DataTable<TData, TValue>({
}
>
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as
| { headerClassName?: string; cellClassName?: string }
| undefined;
const meta = cell.column.columnDef.meta;
return (
<TableCell
key={cell.id}
@@ -451,26 +442,28 @@ export function DataTable<TData, TValue>({
<p className="text-muted-foreground hidden text-xs sm:inline sm:text-sm">
{table.getFilteredRowModel().rows.length === 0
? "No entries"
: `Showing ${table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1
} to ${Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length,
)} of ${table.getFilteredRowModel().rows.length} entries`}
: `Showing ${
table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1
} to ${Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length,
)} of ${table.getFilteredRowModel().rows.length} entries`}
</p>
<p className="text-muted-foreground text-xs sm:hidden">
{table.getFilteredRowModel().rows.length === 0
? "0"
: `${table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1
}-${Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length,
)} of ${table.getFilteredRowModel().rows.length}`}
: `${
table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1
}-${Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length,
)} of ${table.getFilteredRowModel().rows.length}`}
</p>
<Select
value={table.getState().pagination.pageSize.toString()}
@@ -87,8 +87,9 @@ function SortableItem({
<div
ref={setNodeRef}
style={style}
className={`card-secondary transition-colors ${isDragging ? "opacity-50 shadow-lg" : ""
}`}
className={`card-secondary transition-colors ${
isDragging ? "opacity-50 shadow-lg" : ""
}`}
>
{/* Desktop Layout - Hidden on Mobile */}
<div className="hidden items-center gap-3 p-4 md:grid md:grid-cols-12">
@@ -153,7 +154,7 @@ function SortableItem({
{/* Amount */}
<div className="col-span-1">
<div className="bg-muted/30 text-primary flex h-9 items-center border px-3 font-medium">
<div className="bg-muted/30 text-primary flex h-9 items-center border px-3 font-medium">
${item.amount.toFixed(2)}
</div>
</div>
@@ -265,7 +266,7 @@ function SortableItem({
</div>
{/* Amount */}
<div className="bg-muted/20 border p-3">
<div className="bg-muted/20 border p-3">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">Total Amount:</span>
<span className="text-primary font-mono text-lg font-bold">
@@ -360,10 +361,7 @@ export function EditableInvoiceItems({
return (
<div className="space-y-3">
{items.map((item, _index) => (
<div
key={item.id}
className="card-secondary animate-pulse p-4"
>
<div key={item.id} className="card-secondary animate-pulse p-4">
{/* Desktop Skeleton */}
<div className="hidden grid-cols-12 gap-3 md:grid">
<div className="col-span-1">
+1 -1
View File
@@ -80,7 +80,7 @@ export function StatsCard({
)}
</div>
{Icon && (
<div className={cn(" p-3", styles.background)}>
<div className={cn("p-3", styles.background)}>
<Icon className={cn("h-6 w-6", styles.icon)} />
</div>
)}
+1
View File
@@ -143,6 +143,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Load business data when editing
useEffect(() => {
if (business && mode === "edit") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
setFormData({
name: business.name,
nickname: business.nickname ?? "",
+1
View File
@@ -119,6 +119,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
// Load client data when editing
useEffect(() => {
if (client && mode === "edit") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form.
setFormData({
name: client.name,
email: client.email ?? "",
+4 -4
View File
@@ -56,7 +56,7 @@ function FilePreview({
return (
<div
className={cn(
"flex items-center justify-between border p-3",
"flex items-center justify-between border p-3",
getStatusColor(),
)}
>
@@ -152,7 +152,7 @@ export function FileUpload({
<div
{...getRootProps()}
className={cn(
"cursor-pointer border-2 border-dashed p-8 text-center transition-colors",
"cursor-pointer border-2 border-dashed p-8 text-center transition-colors",
"hover:border-primary/40 hover:bg-primary/10",
isDragActive && "border-primary/40 bg-primary/10",
isDragReject && "border-destructive/40 bg-destructive/10",
@@ -164,7 +164,7 @@ export function FileUpload({
<div className="flex flex-col items-center gap-4">
<div
className={cn(
" p-3 transition-colors",
"p-3 transition-colors",
isDragActive ? "bg-primary/10" : "bg-muted",
isDragReject && "bg-destructive/10",
)}
@@ -222,7 +222,7 @@ export function FileUpload({
{/* Error Summary */}
{Object.keys(errors).length > 0 && (
<div className="border-destructive/20 bg-destructive/10 border p-3">
<div className="border-destructive/20 bg-destructive/10 border p-3">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="text-destructive h-4 w-4" />
<span className="text-destructive text-sm font-medium">
+492 -370
View File
@@ -1,398 +1,520 @@
"use client";
import * as React from "react";
import { format, startOfWeek, endOfWeek, eachDayOfInterval, isSameDay, subWeeks, addWeeks, subMonths, addMonths } from "date-fns";
import {
format,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameDay,
subWeeks,
addWeeks,
subMonths,
addMonths,
} from "date-fns";
import { Calendar } from "~/components/ui/calendar";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from "~/components/ui/sheet";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { NumberInput } from "~/components/ui/number-input";
import { Plus, Trash2, Clock, Calendar as CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
import {
Plus,
Trash2,
Clock,
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { cn } from "~/lib/utils";
interface InvoiceItem {
id: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
id: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}
interface InvoiceCalendarViewProps {
items: InvoiceItem[];
onUpdateItem: (
index: number,
field: string,
value: string | number | Date
) => void;
onAddItem: (date?: Date) => void;
onRemoveItem: (index: number) => void;
className?: string;
defaultHourlyRate: number | null;
items: InvoiceItem[];
onUpdateItem: (
index: number,
field: string,
value: string | number | Date,
) => void;
onAddItem: (date?: Date) => void;
onRemoveItem: (index: number) => void;
className?: string;
defaultHourlyRate: number | null;
}
export function InvoiceCalendarView({
items,
onUpdateItem,
onAddItem,
onRemoveItem,
className,
defaultHourlyRate: _defaultHourlyRate,
items,
onUpdateItem,
onAddItem,
onRemoveItem,
className,
defaultHourlyRate: _defaultHourlyRate,
}: InvoiceCalendarViewProps) {
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
const [view, setView] = React.useState<"month" | "week">("month");
const [sheetOpen, setSheetOpen] = React.useState(false);
// Derived state for selected date items - solves cursor jumping
const selectedDateItems = React.useMemo(() => {
if (!date) return [];
return items
.map((item, index) => ({ item, index }))
.filter((wrapper) => {
const itemDate = new Date(wrapper.item.date);
return isSameDay(itemDate, date);
});
}, [items, date]);
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
const [view, setView] = React.useState<"month" | "week">("month");
const [sheetOpen, setSheetOpen] = React.useState(false);
// Derived state for selected date items - solves cursor jumping
const selectedDateItems = React.useMemo(() => {
if (!date) return [];
return items
.map((item, index) => ({ item, index }))
.filter((wrapper) => {
const itemDate = new Date(wrapper.item.date);
return isSameDay(itemDate, date);
});
}, [items, date]);
// Helper to get items for any date (for calendar view)
const getItemsForDate = React.useCallback((targetDate: Date) => {
return items
.map((item, index) => ({ item, index }))
.filter((wrapper) => {
const itemDate = new Date(wrapper.item.date);
return isSameDay(itemDate, targetDate);
});
}, [items]);
// Helper to get items for any date (for calendar view)
const getItemsForDate = React.useCallback(
(targetDate: Date) => {
return items
.map((item, index) => ({ item, index }))
.filter((wrapper) => {
const itemDate = new Date(wrapper.item.date);
return isSameDay(itemDate, targetDate);
});
},
[items],
);
const handleSelectDate = (newDate: Date | undefined) => {
if (!newDate) return;
setDate(newDate);
setSheetOpen(true);
};
const handleSelectDate = (newDate: Date | undefined) => {
if (!newDate) return;
setDate(newDate);
setSheetOpen(true);
};
const handleAddNewItem = () => {
if (date) {
onAddItem(date);
}
};
const handleAddNewItem = () => {
if (date) {
onAddItem(date);
}
};
// Week View Logic - Uses viewDate
const currentWeekStart = startOfWeek(viewDate);
const currentWeekEnd = endOfWeek(viewDate);
const weekDays = eachDayOfInterval({ start: currentWeekStart, end: currentWeekEnd });
// Week View Logic - Uses viewDate
const currentWeekStart = startOfWeek(viewDate);
const currentWeekEnd = endOfWeek(viewDate);
const weekDays = eachDayOfInterval({
start: currentWeekStart,
end: currentWeekEnd,
});
const handleCloseSheet = (isOpen: boolean) => {
setSheetOpen(isOpen);
if (!isOpen) {
setDate(undefined);
}
};
const handleCloseSheet = (isOpen: boolean) => {
setSheetOpen(isOpen);
if (!isOpen) {
setDate(undefined);
}
};
return (
<div className={cn("flex flex-col gap-4 h-full w-full", className)}>
<div className="flex items-center justify-between px-4 pt-4 w-full gap-4">
{/* Navigation Controls */}
<div className="flex items-center gap-2">
{view === "week" ? (
<>
<Button variant="outline" size="icon" onClick={() => setViewDate(d => subWeeks(d, 1))} className="h-8 w-8 rounded-lg">
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium w-36 text-center">
{`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`}
</span>
<Button variant="outline" size="icon" onClick={() => setViewDate(d => addWeeks(d, 1))} className="h-8 w-8 rounded-lg">
<ChevronRight className="h-4 w-4" />
</Button>
</>
) : (
<>
<Button variant="outline" size="icon" onClick={() => setViewDate(d => subMonths(d, 1))} className="h-8 w-8 rounded-lg">
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium w-36 text-center">
{format(viewDate, "MMMM yyyy")}
</span>
<Button variant="outline" size="icon" onClick={() => setViewDate(d => addMonths(d, 1))} className="h-8 w-8 rounded-lg">
<ChevronRight className="h-4 w-4" />
</Button>
</>
)}
</div>
<div className="flex items-center space-x-2 ml-auto">
{/* View Switcher */}
<div className="bg-muted p-1 rounded-lg flex text-sm">
<button
type="button"
onClick={() => setView("month")}
className={cn("px-3 py-1.5 rounded-md transition-all text-center font-medium", view === "month" ? "bg-background shadow text-foreground" : "text-muted-foreground hover:text-foreground")}
>
Month
</button>
<button
type="button"
onClick={() => setView("week")}
className={cn("px-3 py-1.5 rounded-md transition-all text-center font-medium", view === "week" ? "bg-background shadow text-foreground" : "text-muted-foreground hover:text-foreground")}
>
Week
</button>
</div>
</div>
</div>
<div className="flex-1 w-full overflow-hidden">
{view === "month" ? (
<Calendar
mode="single"
selected={date}
onSelect={handleSelectDate}
month={viewDate}
onMonthChange={setViewDate}
className="rounded-md border-0 w-full p-0"
classNames={{
root: "w-full p-0",
months: "flex flex-col w-full",
month: "flex flex-col w-full space-y-4",
// Grid - Revert to Flex but Enforce 1/7th Width
// table: "w-full border-collapse", // No table-fixed
head_row: "flex w-full",
row: "flex w-full mt-2",
// Cells & Headers: Explicit width 14.28%
// Use calc(100%/7) via tailwind arbitrary or just flex bases.
// Better: w-[14.28%] flex-none (approx 1/7)
weekdays: "flex w-full border-b",
weekday: "w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
week: "flex w-full mt-2",
cell: "w-[14.285%] flex-none h-20 sm:h-28 md:h-32 border-b p-0 relative focus-within:relative focus-within:z-20 text-center text-sm",
// Hide internal navigation & caption entirely
nav: "hidden",
caption: "hidden",
day: cn(
"w-full h-full p-2 font-normal aria-selected:opacity-100 flex flex-col items-start justify-start gap-1 hover:bg-accent/50 hover:text-accent-foreground align-top transition-colors rounded-xl"
),
day_selected: "bg-primary/5 text-primary",
day_today: "bg-accent/20",
day_outside: "text-muted-foreground opacity-30",
}}
formatters={{
formatMonthCaption: () => "", // Clear default caption text to prevent duplication
}}
components={{
DayButton: (props) => {
const { day, modifiers, className, ...buttonProps } = props;
const DayDate = day.date;
const dayItems = getItemsForDate(DayDate);
// const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0); // Unused now
return (
<button
{...buttonProps}
type="button"
className={cn(
"relative flex h-full w-full flex-col items-start justify-between p-2 transition-all rounded-xl border border-transparent hover:border-border/50 hover:bg-secondary/30 text-left overflow-hidden",
// Selected State: Filled Box, No Outline
modifiers.selected && "bg-primary text-primary-foreground hover:bg-primary/90 shadow-md transform scale-[0.98]",
modifiers.today && !modifiers.selected && "bg-accent/40 rounded-xl",
className
)}
>
<span className="text-sm font-medium z-10">{DayDate.getDate()}</span>
{dayItems.length > 0 && (
<div className="flex flex-col gap-1 w-full mt-1 overflow-hidden h-full justify-end pb-1">
<div className="flex flex-col gap-1 w-full mt-1">
{dayItems.slice(0, 4).map((item, idx) => (
<div key={idx} className={cn("h-1 w-full rounded-full", modifiers.selected ? "bg-primary-foreground/50" : "bg-primary/50")} />
))}
{dayItems.length > 4 && <div className={cn("h-1 w-1/3 rounded-full", modifiers.selected ? "bg-primary-foreground/30" : "bg-muted-foreground/30")} />}
</div>
</div>
)}
</button>
);
}
}}
/>
) : (
<div className="flex gap-3 overflow-x-auto p-4 pb-6 w-full">
{weekDays.map((day) => {
const isSelected = date && isSameDay(day, date);
const isToday = isSameDay(day, new Date());
const dayItems = getItemsForDate(day);
const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0);
return (
<button
key={day.toString()}
type="button"
onClick={() => handleSelectDate(day)}
className={cn(
"flex flex-col min-h-[260px] flex-shrink-0 w-[120px] sm:flex-1 sm:w-auto border rounded-3xl p-3 text-left transition-all hover:bg-accent/30",
isSelected ? "ring-2 ring-primary ring-offset-2 bg-primary/5" : "bg-background/40",
isToday && !isSelected ? "bg-accent/40" : ""
)}
>
<div className="flex flex-col items-center mb-4 pb-4 border-b w-full">
<span className="text-xs font-bold text-muted-foreground uppercase">{format(day, "EEE")}</span>
<span className="text-2xl font-light">{format(day, "d")}</span>
</div>
<div className="flex-1 space-y-2 w-full overflow-hidden">
{dayItems.length > 0 ? (
dayItems.map(({ item }, i) => (
<div key={i} className="bg-background rounded-xl p-2 text-xs shadow-sm border">
<div className="font-medium line-clamp-2 text-wrap break-words">{item.description || "No description"}</div>
<div className="text-muted-foreground whitespace-nowrap">{item.hours}h</div>
</div>
))
) : (
<div className="h-full flex items-center justify-center text-muted-foreground/20">
<Plus className="w-8 h-8" />
</div>
)}
</div>
{dayItems.length > 0 && (
<div className="pt-2 mt-auto text-center w-full">
<span className="text-sm font-semibold">{totalHours}h Total</span>
</div>
)}
</button>
);
})}
</div>
)}
</div>
{/* Sheet for Day Details */}
<Sheet
open={sheetOpen}
onOpenChange={handleCloseSheet}
>
<SheetContent side="right" className="w-full max-w-full sm:w-[400px] sm:max-w-[540px] flex flex-col gap-0 p-0">
<SheetHeader className="p-6 border-b">
<SheetTitle className="flex items-center gap-3 text-2xl flex-wrap">
<div className="bg-primary/10 p-2.5 rounded-full flex-shrink-0">
<CalendarIcon className="w-6 h-6 text-primary" />
</div>
<span className="break-words text-left">{date ? format(date, "EEEE, MMMM do") : "Details"}</span>
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-6">
{date && selectedDateItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4 bg-secondary/20 rounded-3xl border border-dashed border-border/60">
<div className="bg-background p-4 rounded-full shadow-sm">
<Clock className="w-8 h-8 text-muted-foreground/50" />
</div>
<div className="space-y-1">
<p className="font-semibold text-lg text-foreground">No hours logged</p>
<p className="text-sm text-muted-foreground/80 max-w-[200px]">There are no time entries recorded for this day yet.</p>
</div>
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
<Plus className="w-4 h-4 mr-2" />
Log Time
</Button>
</div>
) : (
<div className="space-y-4">
{selectedDateItems.map(({ item, index }) => (
<div key={item.id} className="border-border bg-card overflow-hidden rounded-lg border group hover:border-primary/50 transition-colors">
<div className="space-y-3 p-4">
{/* Description */}
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Description</Label>
<Input
value={item.description}
onChange={(e) => onUpdateItem(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="pl-3 text-sm"
/>
</div>
{/* Hours and Rate in a row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Hours</Label>
<NumberInput
value={item.hours}
onChange={v => onUpdateItem(index, "hours", v)}
step={0.25}
min={0}
width="full"
/>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Rate</Label>
<NumberInput
value={item.rate}
onChange={v => onUpdateItem(index, "rate", v)}
prefix="$"
min={0}
step={1}
width="full"
/>
</div>
</div>
</div>
{/* Bottom section with controls, item name, and total */}
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemoveItem(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 px-3 text-center">
<span className="text-muted-foreground block text-sm font-medium">
Item #{index + 1}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs">Total</span>
<span className="text-primary text-lg font-bold">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
</div>
</div>
))}
<Button variant="outline" onClick={handleAddNewItem} className="w-full border-dashed py-8 rounded-xl hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary transition-all gap-2 group">
<div className="bg-muted group-hover:bg-primary/10 p-1 rounded-md transition-colors">
<Plus className="w-4 h-4" />
</div>
<span>Add Another Entry</span>
</Button>
</div>
)}
</div>
</div>
<SheetFooter className="p-6 border-t bg-muted/10 mt-auto">
<Button className="w-full sm:w-full rounded-xl h-12 text-base shadow-md" size="lg" onClick={() => handleCloseSheet(false)}>Done</Button>
</SheetFooter>
</SheetContent>
</Sheet>
return (
<div className={cn("flex h-full w-full flex-col gap-4", className)}>
<div className="flex w-full items-center justify-between gap-4 px-4 pt-4">
{/* Navigation Controls */}
<div className="flex items-center gap-2">
{view === "week" ? (
<>
<Button
variant="outline"
size="icon"
onClick={() => setViewDate((d) => subWeeks(d, 1))}
className="h-8 w-8 rounded-lg"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="w-36 text-center text-sm font-medium">
{`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`}
</span>
<Button
variant="outline"
size="icon"
onClick={() => setViewDate((d) => addWeeks(d, 1))}
className="h-8 w-8 rounded-lg"
>
<ChevronRight className="h-4 w-4" />
</Button>
</>
) : (
<>
<Button
variant="outline"
size="icon"
onClick={() => setViewDate((d) => subMonths(d, 1))}
className="h-8 w-8 rounded-lg"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="w-36 text-center text-sm font-medium">
{format(viewDate, "MMMM yyyy")}
</span>
<Button
variant="outline"
size="icon"
onClick={() => setViewDate((d) => addMonths(d, 1))}
className="h-8 w-8 rounded-lg"
>
<ChevronRight className="h-4 w-4" />
</Button>
</>
)}
</div>
);
<div className="ml-auto flex items-center space-x-2">
{/* View Switcher */}
<div className="bg-muted flex rounded-lg p-1 text-sm">
<button
type="button"
onClick={() => setView("month")}
className={cn(
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
view === "month"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
)}
>
Month
</button>
<button
type="button"
onClick={() => setView("week")}
className={cn(
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
view === "week"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
)}
>
Week
</button>
</div>
</div>
</div>
<div className="w-full flex-1 overflow-hidden">
{view === "month" ? (
<Calendar
mode="single"
selected={date}
onSelect={handleSelectDate}
month={viewDate}
onMonthChange={setViewDate}
className="w-full rounded-md border-0 p-0"
classNames={{
root: "w-full p-0",
months: "flex flex-col w-full",
month: "flex flex-col w-full space-y-4",
// Grid - Revert to Flex but Enforce 1/7th Width
// table: "w-full border-collapse", // No table-fixed
head_row: "flex w-full",
row: "flex w-full mt-2",
// Cells & Headers: Explicit width 14.28%
// Use calc(100%/7) via tailwind arbitrary or just flex bases.
// Better: w-[14.28%] flex-none (approx 1/7)
weekdays: "flex w-full border-b",
weekday:
"w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
week: "flex w-full mt-2",
cell: "w-[14.285%] flex-none h-20 sm:h-28 md:h-32 border-b p-0 relative focus-within:relative focus-within:z-20 text-center text-sm",
// Hide internal navigation & caption entirely
nav: "hidden",
caption: "hidden",
day: cn(
"w-full h-full p-2 font-normal aria-selected:opacity-100 flex flex-col items-start justify-start gap-1 hover:bg-accent/50 hover:text-accent-foreground align-top transition-colors rounded-xl",
),
day_selected: "bg-primary/5 text-primary",
day_today: "bg-accent/20",
day_outside: "text-muted-foreground opacity-30",
}}
formatters={{
formatMonthCaption: () => "", // Clear default caption text to prevent duplication
}}
components={{
DayButton: (props) => {
const { day, modifiers, className, ...buttonProps } = props;
const DayDate = day.date;
const dayItems = getItemsForDate(DayDate);
// const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0); // Unused now
return (
<button
{...buttonProps}
type="button"
className={cn(
"hover:border-border/50 hover:bg-secondary/30 relative flex h-full w-full flex-col items-start justify-between overflow-hidden rounded-xl border border-transparent p-2 text-left transition-all",
// Selected State: Filled Box, No Outline
modifiers.selected &&
"bg-primary text-primary-foreground hover:bg-primary/90 scale-[0.98] transform shadow-md",
modifiers.today &&
!modifiers.selected &&
"bg-accent/40 rounded-xl",
className,
)}
>
<span className="z-10 text-sm font-medium">
{DayDate.getDate()}
</span>
{dayItems.length > 0 && (
<div className="mt-1 flex h-full w-full flex-col justify-end gap-1 overflow-hidden pb-1">
<div className="mt-1 flex w-full flex-col gap-1">
{dayItems.slice(0, 4).map((item, idx) => (
<div
key={idx}
className={cn(
"h-1 w-full rounded-full",
modifiers.selected
? "bg-primary-foreground/50"
: "bg-primary/50",
)}
/>
))}
{dayItems.length > 4 && (
<div
className={cn(
"h-1 w-1/3 rounded-full",
modifiers.selected
? "bg-primary-foreground/30"
: "bg-muted-foreground/30",
)}
/>
)}
</div>
</div>
)}
</button>
);
},
}}
/>
) : (
<div className="flex w-full gap-3 overflow-x-auto p-4 pb-6">
{weekDays.map((day) => {
const isSelected = date && isSameDay(day, date);
const isToday = isSameDay(day, new Date());
const dayItems = getItemsForDate(day);
const totalHours = dayItems.reduce(
(acc, curr) => acc + curr.item.hours,
0,
);
return (
<button
key={day.toString()}
type="button"
onClick={() => handleSelectDate(day)}
className={cn(
"hover:bg-accent/30 flex min-h-[260px] w-[120px] flex-shrink-0 flex-col rounded-3xl border p-3 text-left transition-all sm:w-auto sm:flex-1",
isSelected
? "ring-primary bg-primary/5 ring-2 ring-offset-2"
: "bg-background/40",
isToday && !isSelected ? "bg-accent/40" : "",
)}
>
<div className="mb-4 flex w-full flex-col items-center border-b pb-4">
<span className="text-muted-foreground text-xs font-bold uppercase">
{format(day, "EEE")}
</span>
<span className="text-2xl font-light">
{format(day, "d")}
</span>
</div>
<div className="w-full flex-1 space-y-2 overflow-hidden">
{dayItems.length > 0 ? (
dayItems.map(({ item }, i) => (
<div
key={i}
className="bg-background rounded-xl border p-2 text-xs shadow-sm"
>
<div className="line-clamp-2 font-medium text-wrap break-words">
{item.description || "No description"}
</div>
<div className="text-muted-foreground whitespace-nowrap">
{item.hours}h
</div>
</div>
))
) : (
<div className="text-muted-foreground/20 flex h-full items-center justify-center">
<Plus className="h-8 w-8" />
</div>
)}
</div>
{dayItems.length > 0 && (
<div className="mt-auto w-full pt-2 text-center">
<span className="text-sm font-semibold">
{totalHours}h Total
</span>
</div>
)}
</button>
);
})}
</div>
)}
</div>
{/* Sheet for Day Details */}
<Sheet open={sheetOpen} onOpenChange={handleCloseSheet}>
<SheetContent
side="right"
className="flex w-full max-w-full flex-col gap-0 p-0 sm:w-[400px] sm:max-w-[540px]"
>
<SheetHeader className="border-b p-6">
<SheetTitle className="flex flex-wrap items-center gap-3 text-2xl">
<div className="bg-primary/10 flex-shrink-0 rounded-full p-2.5">
<CalendarIcon className="text-primary h-6 w-6" />
</div>
<span className="text-left break-words">
{date ? format(date, "EEEE, MMMM do") : "Details"}
</span>
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-6">
{date && selectedDateItems.length === 0 ? (
<div className="bg-secondary/20 border-border/60 flex flex-col items-center justify-center space-y-4 rounded-3xl border border-dashed py-16 text-center">
<div className="bg-background rounded-full p-4 shadow-sm">
<Clock className="text-muted-foreground/50 h-8 w-8" />
</div>
<div className="space-y-1">
<p className="text-foreground text-lg font-semibold">
No hours logged
</p>
<p className="text-muted-foreground/80 max-w-[200px] text-sm">
There are no time entries recorded for this day yet.
</p>
</div>
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
<Plus className="mr-2 h-4 w-4" />
Log Time
</Button>
</div>
) : (
<div className="space-y-4">
{selectedDateItems.map(({ item, index }) => (
<div
key={item.id}
className="border-border bg-card group hover:border-primary/50 overflow-hidden rounded-lg border transition-colors"
>
<div className="space-y-3 p-4">
{/* Description */}
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">
Description
</Label>
<Input
value={item.description}
onChange={(e) =>
onUpdateItem(index, "description", e.target.value)
}
placeholder="Describe the work performed..."
className="pl-3 text-sm"
/>
</div>
{/* Hours and Rate in a row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">
Hours
</Label>
<NumberInput
value={item.hours}
onChange={(v) => onUpdateItem(index, "hours", v)}
step={0.25}
min={0}
width="full"
/>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">
Rate
</Label>
<NumberInput
value={item.rate}
onChange={(v) => onUpdateItem(index, "rate", v)}
prefix="$"
min={0}
step={1}
width="full"
/>
</div>
</div>
</div>
{/* Bottom section with controls, item name, and total */}
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemoveItem(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 px-3 text-center">
<span className="text-muted-foreground block text-sm font-medium">
Item #{index + 1}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs">
Total
</span>
<span className="text-primary text-lg font-bold">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
</div>
</div>
))}
<Button
variant="outline"
onClick={handleAddNewItem}
className="hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary group w-full gap-2 rounded-xl border-dashed py-8 transition-all"
>
<div className="bg-muted group-hover:bg-primary/10 rounded-md p-1 transition-colors">
<Plus className="h-4 w-4" />
</div>
<span>Add Another Entry</span>
</Button>
</div>
)}
</div>
</div>
<SheetFooter className="bg-muted/10 mt-auto border-t p-6">
<Button
className="h-12 w-full rounded-xl text-base shadow-md sm:w-full"
size="lg"
onClick={() => handleCloseSheet(false)}
>
Done
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
);
}
+15 -7
View File
@@ -93,12 +93,8 @@ function plainTextToHtml(value: string) {
.replace(/\n/g, "<br>");
}
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter();
const utils = api.useUtils();
// State
const [formData, setFormData] = useState<InvoiceFormData>({
function createDefaultInvoiceFormData(): InvoiceFormData {
return {
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
invoicePrefix: "#",
businessId: "",
@@ -121,7 +117,17 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
amount: 0,
},
],
});
};
}
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter();
const utils = api.useUtils();
// State
const [formData, setFormData] = useState<InvoiceFormData>(
createDefaultInvoiceFormData,
);
const [loading, setLoading] = useState(false);
const [initialized, setInitialized] = useState(false);
@@ -153,6 +159,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
// Init Effects (Same as before)
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset initialization state when the routed invoice changes.
setInitialized(false);
}, [invoiceId]);
useEffect(() => {
@@ -167,6 +174,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
rate: item.rate,
amount: item.amount,
})) || [];
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
setFormData({
invoiceNumber: existingInvoice.invoiceNumber,
invoicePrefix: existingInvoice.invoicePrefix ?? "#",
@@ -7,196 +7,212 @@ import { Textarea } from "~/components/ui/textarea";
import { DatePicker } from "~/components/ui/date-picker";
import { NumberInput } from "~/components/ui/number-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
STATUS_OPTIONS,
} from "./types";
import type {
InvoiceFormData,
ClientType,
BusinessType,
} from "./types";
import { STATUS_OPTIONS } from "./types";
import type { InvoiceFormData, ClientType, BusinessType } from "./types";
interface InvoiceMetaSidebarProps {
formData: InvoiceFormData;
updateField: <K extends keyof InvoiceFormData>(
field: K,
value: InvoiceFormData[K]
) => void;
clients: ClientType[] | undefined;
businesses: BusinessType[] | undefined;
className?: string;
formData: InvoiceFormData;
updateField: <K extends keyof InvoiceFormData>(
field: K,
value: InvoiceFormData[K],
) => void;
clients: ClientType[] | undefined;
businesses: BusinessType[] | undefined;
className?: string;
}
export function InvoiceMetaSidebar({
formData,
updateField,
clients,
businesses,
className,
formData,
updateField,
clients,
businesses,
className,
}: InvoiceMetaSidebarProps) {
return (
<div className={cn("flex flex-col gap-6 p-4 h-full", className)}>
<div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
Invoice Details
</h3>
return (
<div className={cn("flex h-full flex-col gap-6 p-4", className)}>
<div className="space-y-4">
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Invoice Details
</h3>
{/* Status */}
<div className="space-y-1.5">
<Label htmlFor="status" className="text-xs">Status</Label>
<Select
value={formData.status}
onValueChange={(value: "draft" | "sent" | "paid") =>
updateField("status", value)
}
>
<SelectTrigger className="bg-background/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Invoice Number */}
<div className="space-y-1.5">
<Label htmlFor="invoiceNumber" className="text-xs">Invoice Number</Label>
<Input
id="invoiceNumber"
value={formData.invoiceNumber}
placeholder="INV-..."
disabled
className="bg-muted/50 font-mono text-sm"
/>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
Involved Parties
</h3>
{/* From (Business) */}
<div className="space-y-1.5">
<Label htmlFor="business" className="text-xs">From (Business)</Label>
<Select
value={formData.businessId}
onValueChange={(value) => updateField("businessId", value)}
>
<SelectTrigger aria-label="From Business" className="bg-background/50 text-sm">
<span className="truncate">
<SelectValue placeholder="Select business" />
</span>
</SelectTrigger>
<SelectContent>
{businesses?.map((business) => (
<SelectItem key={business.id} value={business.id}>
{business.name}{business.nickname ? ` (${business.nickname})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Bill To (Client) */}
<div className="space-y-1.5">
<Label htmlFor="client" className="text-xs">Bill To (Client)</Label>
<Select
value={formData.clientId}
onValueChange={(value) => updateField("clientId", value)}
>
<SelectTrigger aria-label="Bill To Client" className="bg-background/50 text-sm">
<span className="truncate">
<SelectValue placeholder="Select client" />
</span>
</SelectTrigger>
<SelectContent>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
Dates
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Issued</Label>
<DatePicker
date={formData.issueDate}
onDateChange={(date) => updateField("issueDate", date ?? new Date())}
className="w-full bg-background/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Due</Label>
<DatePicker
date={formData.dueDate}
onDateChange={(date) => updateField("dueDate", date ?? new Date())}
className="w-full bg-background/50"
/>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
Config
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Tax Rate</Label>
<NumberInput
value={formData.taxRate}
onChange={(v) => updateField("taxRate", v)}
min={0}
max={100}
step={1}
suffix="%"
className="bg-background/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Hourly Rate</Label>
<NumberInput
value={formData.defaultHourlyRate ?? 0}
onChange={(v) => updateField("defaultHourlyRate", v)}
min={0}
prefix="$"
placeholder={!formData.clientId ? "Select client" : "Rate"}
disabled={!formData.clientId}
className={cn("bg-background/50", !formData.clientId && "opacity-50")}
/>
</div>
</div>
</div>
<div className="space-y-1.5 flex-1">
<Label className="text-xs">Notes</Label>
<Textarea
value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)}
placeholder="Notes for client..."
className="bg-background/50 resize-none h-24"
/>
</div>
{/* Status */}
<div className="space-y-1.5">
<Label htmlFor="status" className="text-xs">
Status
</Label>
<Select
value={formData.status}
onValueChange={(value: "draft" | "sent" | "paid") =>
updateField("status", value)
}
>
<SelectTrigger className="bg-background/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
{/* Invoice Number */}
<div className="space-y-1.5">
<Label htmlFor="invoiceNumber" className="text-xs">
Invoice Number
</Label>
<Input
id="invoiceNumber"
value={formData.invoiceNumber}
placeholder="INV-..."
disabled
className="bg-muted/50 font-mono text-sm"
/>
</div>
</div>
<div className="space-y-4">
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Involved Parties
</h3>
{/* From (Business) */}
<div className="space-y-1.5">
<Label htmlFor="business" className="text-xs">
From (Business)
</Label>
<Select
value={formData.businessId}
onValueChange={(value) => updateField("businessId", value)}
>
<SelectTrigger
aria-label="From Business"
className="bg-background/50 text-sm"
>
<span className="truncate">
<SelectValue placeholder="Select business" />
</span>
</SelectTrigger>
<SelectContent>
{businesses?.map((business) => (
<SelectItem key={business.id} value={business.id}>
{business.name}
{business.nickname ? ` (${business.nickname})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Bill To (Client) */}
<div className="space-y-1.5">
<Label htmlFor="client" className="text-xs">
Bill To (Client)
</Label>
<Select
value={formData.clientId}
onValueChange={(value) => updateField("clientId", value)}
>
<SelectTrigger
aria-label="Bill To Client"
className="bg-background/50 text-sm"
>
<span className="truncate">
<SelectValue placeholder="Select client" />
</span>
</SelectTrigger>
<SelectContent>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Dates
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Issued</Label>
<DatePicker
date={formData.issueDate}
onDateChange={(date) =>
updateField("issueDate", date ?? new Date())
}
className="bg-background/50 w-full"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Due</Label>
<DatePicker
date={formData.dueDate}
onDateChange={(date) =>
updateField("dueDate", date ?? new Date())
}
className="bg-background/50 w-full"
/>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Config
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Tax Rate</Label>
<NumberInput
value={formData.taxRate}
onChange={(v) => updateField("taxRate", v)}
min={0}
max={100}
step={1}
suffix="%"
className="bg-background/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Hourly Rate</Label>
<NumberInput
value={formData.defaultHourlyRate ?? 0}
onChange={(v) => updateField("defaultHourlyRate", v)}
min={0}
prefix="$"
placeholder={!formData.clientId ? "Select client" : "Rate"}
disabled={!formData.clientId}
className={cn(
"bg-background/50",
!formData.clientId && "opacity-50",
)}
/>
</div>
</div>
</div>
<div className="flex-1 space-y-1.5">
<Label className="text-xs">Notes</Label>
<Textarea
value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)}
placeholder="Notes for client..."
className="bg-background/50 h-24 resize-none"
/>
</div>
</div>
);
}
+68 -60
View File
@@ -2,7 +2,10 @@
import * as React from "react";
import { Sidebar } from "~/components/layout/sidebar";
import { SidebarProvider, useSidebar } from "~/components/layout/sidebar-provider";
import {
SidebarProvider,
useSidebar,
} from "~/components/layout/sidebar-provider";
import { cn } from "~/lib/utils";
import { Menu } from "lucide-react";
import { Logo } from "~/components/branding/logo";
@@ -11,70 +14,75 @@ import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { useAppearance } from "~/components/providers/appearance-provider";
function DashboardContent({ children }: { children: React.ReactNode }) {
const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance();
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance();
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
return (
<div className="bg-dashboard relative min-h-screen flex">
{/* Desktop Sidebar */}
<div className="hidden md:block">
<Sidebar />
</div>
return (
<div className="bg-dashboard relative flex min-h-screen">
{/* Desktop Sidebar */}
<div className="hidden md:block">
<Sidebar />
</div>
{/* Mobile Sidebar (Sheet) */}
<div className="fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b bg-background/80 px-4 backdrop-blur-md md:hidden">
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="h-10 w-10 bg-background shadow-sm" suppressHydrationWarning>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
{/* Mobile Link / Logo */}
<div className="ml-4 flex items-center gap-2">
<Logo size="sm" />
</div>
<SheetContent side="left" className="p-0 w-72">
<div className="sr-only">
<h2 id="mobile-nav-title">Navigation Menu</h2>
</div>
<Sidebar mobile onClose={() => setIsMobileOpen(false)} />
</SheetContent>
</Sheet>
</div>
{/* Main Content */}
<main
suppressHydrationWarning
className={cn(
"flex-1 min-h-screen min-w-0 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",
)}
{/* 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">
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
className="bg-background h-10 w-10 shadow-sm"
suppressHydrationWarning
>
<div className="p-4 pt-16 md:pt-4">
{/* Mobile header spacer is handled by pt-16 on mobile */}
<div className="md:hidden mb-4">
{/* Mobile Breadcrumbs could go here or be part of the page */}
</div>
{children}
</div>
</main>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
{/* Mobile Link / Logo */}
<div className="ml-4 flex items-center gap-2">
<Logo size="sm" />
</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 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",
)}
>
<div className="dashboard-content-shell p-4 pt-16 md:pt-4">
{/* Mobile header spacer is handled by pt-16 on mobile */}
<div className="mb-4 md:hidden">
{/* Mobile Breadcrumbs could go here or be part of the page */}
</div>
{children}
</div>
);
</main>
</div>
);
}
export function DashboardShell({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider>
<DashboardContent>{children}</DashboardContent>
</SidebarProvider>
);
return (
<SidebarProvider>
<DashboardContent>{children}</DashboardContent>
</SidebarProvider>
);
}
+21 -21
View File
@@ -3,25 +3,25 @@
import { cn } from "~/lib/utils";
export function MotionBackground() {
return (
<div className="fixed inset-0 -z-50 overflow-hidden pointer-events-none bg-background">
<div
className={cn(
"absolute inset-[-50%] w-[200%] h-[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%] w-[200%] h-[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>
);
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>
);
}
+10 -8
View File
@@ -42,22 +42,24 @@ export function PageHeader({
return (
<div className={`animate-fade-in-down mb-6 ${className}`}>
{variant === "large-gradient" || variant === "gradient" ? (
<div className="platform-header-surface rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden relative">
<div className="platform-header-gradient absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent pointer-events-none" />
<div className="platform-header-content p-6 relative">
<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 sm:flex-row sm:items-start sm:justify-between gap-4">
<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`}>
<p
className={`text-muted-foreground ${getDescriptionSpacing()} text-lg`}
>
{description}
</p>
)}
</div>
{children && (
<div className="flex flex-shrink-0 gap-2 sm:gap-3 w-full sm:w-auto">
<div className="flex w-full flex-shrink-0 gap-2 sm:w-auto sm:gap-3">
{children}
</div>
)}
@@ -68,7 +70,7 @@ export function PageHeader({
<>
<DashboardBreadcrumbs className="mb-2 sm:mb-4" />
{/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<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 && (
@@ -80,7 +82,7 @@ export function PageHeader({
)}
</div>
{children && (
<div className="animate-slide-in-right animate-delay-200 flex flex-shrink-0 gap-2 sm:gap-3 w-full sm:w-auto">
<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>
)}
+19 -28
View File
@@ -7,11 +7,7 @@ interface PageLayoutProps {
}
export function PageLayout({ children, className }: PageLayoutProps) {
return (
<div className={cn("min-h-screen", className)}>
{children}
</div>
);
return <div className={cn("min-h-screen", className)}>{children}</div>;
}
interface PageContentProps {
@@ -23,18 +19,16 @@ interface PageContentProps {
export function PageContent({
children,
className,
spacing = "default"
spacing = "default",
}: PageContentProps) {
const spacingClasses = {
default: "space-y-8",
compact: "space-y-4",
large: "space-y-12"
large: "space-y-12",
};
return (
<div className={cn(spacingClasses[spacing], className)}>
{children}
</div>
<div className={cn(spacingClasses[spacing], className)}>{children}</div>
);
}
@@ -51,7 +45,7 @@ export function PageSection({
className,
title,
description,
actions
actions,
}: PageSectionProps) {
return (
<section className={cn("space-y-4", className)}>
@@ -59,15 +53,15 @@ export function PageSection({
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
{title && (
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
<h2 className="text-foreground text-xl font-semibold">{title}</h2>
)}
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
<p className="text-muted-foreground mt-1 text-sm">
{description}
</p>
)}
</div>
{actions && (
<div className="flex flex-shrink-0 gap-3">{actions}</div>
)}
{actions && <div className="flex flex-shrink-0 gap-3">{actions}</div>}
</div>
)}
{children}
@@ -86,28 +80,25 @@ export function PageGrid({
children,
className,
columns = 3,
gap = "default"
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"
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
};
const gapClasses = {
default: "gap-4",
compact: "gap-2",
large: "gap-6"
large: "gap-6",
};
return (
<div className={cn(
"grid",
columnClasses[columns],
gapClasses[gap],
className
)}>
<div
className={cn("grid", columnClasses[columns], gapClasses[gap], className)}
>
{children}
</div>
);
@@ -127,18 +118,18 @@ export function EmptyState({
title,
description,
action,
className
className,
}: EmptyStateProps) {
return (
<div className={cn("py-12 text-center", className)}>
{icon && (
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center bg-muted/50">
<div className="bg-muted/50 mx-auto mb-4 flex h-16 w-16 items-center justify-center">
{icon}
</div>
)}
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
{description && (
<p className="text-muted-foreground mb-4 max-w-sm mx-auto">
<p className="text-muted-foreground mx-auto mb-4 max-w-sm">
{description}
</p>
)}
+2 -2
View File
@@ -56,7 +56,7 @@ export function QuickActionCard({
<CardContent className="p-6 text-center">
<div
className={cn(
"mx-auto mb-3 flex h-12 w-12 items-center justify-center transition-colors",
"mx-auto mb-3 flex h-12 w-12 items-center justify-center transition-colors",
styles.background,
styles.hoverBackground,
)}
@@ -101,7 +101,7 @@ export function QuickActionCardSkeleton() {
<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-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>
+37 -41
View File
@@ -3,58 +3,54 @@
import * as React from "react";
interface SidebarContextType {
isCollapsed: boolean;
toggleCollapse: () => void;
expand: () => void;
collapse: () => void;
isCollapsed: boolean;
toggleCollapse: () => void;
expand: () => void;
collapse: () => void;
}
const SidebarContext = React.createContext<SidebarContextType | undefined>(
undefined,
undefined,
);
export function SidebarProvider({ children }: { children: React.ReactNode }) {
const [isCollapsed, setIsCollapsed] = React.useState(false);
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;
});
// Persist state if needed, for now just local state
React.useEffect(() => {
const saved = localStorage.getItem("sidebar-collapsed");
if (saved) {
setIsCollapsed(JSON.parse(saved) as boolean);
}
}, []);
const toggleCollapse = React.useCallback(() => {
setIsCollapsed((prev) => {
const next = !prev;
localStorage.setItem("sidebar-collapsed", JSON.stringify(next));
return next;
});
}, []);
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 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));
}, []);
const collapse = React.useCallback(() => {
setIsCollapsed(true);
localStorage.setItem("sidebar-collapsed", JSON.stringify(true));
}, []);
return (
<SidebarContext.Provider
value={{ isCollapsed, toggleCollapse, expand, collapse }}
>
{children}
</SidebarContext.Provider>
);
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;
const context = React.useContext(SidebarContext);
if (context === undefined) {
throw new Error("useSidebar must be used within a SidebarProvider");
}
return context;
}
+95 -46
View File
@@ -5,16 +5,17 @@ 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 { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { navigationConfig } 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 {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
@@ -46,10 +47,12 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
<div className="flex h-full flex-col justify-between">
<div>
{/* Header / Logo */}
<div className={cn(
"flex items-center h-14 px-4 mb-2",
collapsed ? "justify-center px-2" : "justify-between"
)}>
<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" />
@@ -63,11 +66,16 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
</div>
{/* Navigation */}
<nav className={cn("flex flex-col px-2 gap-6 mt-4", collapsed && "items-center")}>
<nav
className={cn(
"mt-4 flex flex-col gap-6 px-2",
collapsed && "items-center",
)}
>
{navigationConfig.map((section) => (
<div key={section.title}>
{!collapsed && (
<div className="px-2 mb-2 text-xs font-semibold text-muted-foreground/60 tracking-wider uppercase">
<div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase">
{section.title}
</div>
)}
@@ -84,17 +92,21 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
<TooltipTrigger asChild>
<Link
href={link.href}
data-active={isActive ? "true" : undefined}
className={cn(
"flex items-center justify-center h-10 w-10 rounded-md transition-colors",
"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"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-5 w-5" />
</Link>
</TooltipTrigger>
<TooltipContent side="right" className="font-medium">
<TooltipContent
side="right"
className="font-medium"
>
{link.name}
</TooltipContent>
</Tooltip>
@@ -106,12 +118,13 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
<Link
key={link.href}
href={link.href}
data-active={isActive ? "true" : undefined}
onClick={mobile ? onClose : undefined}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
"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"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-4 w-4" />
@@ -127,29 +140,45 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
</div>
{/* Footer / User */}
<div className="p-2 mt-auto space-y-2">
<div className="mt-auto space-y-2 p-2">
{!mobile && (
<div className={cn("flex", collapsed ? "justify-center" : "justify-end px-2")}>
<div
className={cn(
"flex",
collapsed ? "justify-center" : "justify-end px-2",
)}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
className="text-muted-foreground h-8 w-8"
onClick={toggleCollapse}
>
{collapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
{collapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</Button>
</div>
)}
<div className={cn(
"border-t border-border/50 pt-4",
collapsed ? "flex flex-col items-center gap-2" : "px-2"
)}>
<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")}>
<div
className={cn(
"flex items-center gap-3",
collapsed ? "justify-center" : "px-2",
)}
>
<Skeleton className="h-9 w-9 rounded-full" />
{!collapsed && (
<div className="space-y-1 flex-1">
<div className="flex-1 space-y-1">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-24" />
</div>
@@ -158,17 +187,37 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
) : session?.user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className={cn("w-full justify-start p-0 hover:bg-transparent", collapsed && "justify-center")}>
<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="h-9 w-9 border border-border">
<AvatarImage src={getGravatarUrl(session.user.email)} alt={session.user.name ?? "User"} />
<AvatarFallback>{session.user.name?.[0] ?? "U"}</AvatarFallback>
<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="flex-1 min-w-0 text-left">
<span className="block text-sm font-medium truncate">{session.user.name}</span>
<span className="block text-xs text-muted-foreground truncate">{session.user.email}</span>
<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>
@@ -177,13 +226,17 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
<DropdownMenuContent
side="right"
align="end"
className="w-56 bg-background/80 backdrop-blur-xl border-border/50"
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 font-medium leading-none">{session.user.name}</p>
<p className="text-xs leading-none text-muted-foreground">{session.user.email}</p>
<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 />
@@ -192,7 +245,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
await authClient.signOut();
window.location.href = "/";
}}
className="text-red-600 focus:text-red-600 focus:bg-red-100/50 dark:focus:bg-red-900/20"
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
@@ -206,11 +259,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
);
if (mobile) {
return (
<div className="h-full bg-background">
{SidebarContent}
</div>
);
return <div className="bg-background h-full">{SidebarContent}</div>;
}
return (
@@ -218,8 +267,8 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
className={cn(
"fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex",
sidebarStyle === "floating"
? "top-4 bottom-4 left-4 border-border/50 rounded-3xl border bg-background/80 shadow-xl backdrop-blur-xl"
: "top-0 bottom-0 left-0 rounded-none border-r border-border bg-background shadow-none",
? "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",
isCollapsed ? "w-16" : "w-64",
)}
>
+6 -3
View File
@@ -14,12 +14,15 @@ export function Breadcrumbs() {
})),
];
return (
<nav className="flex items-center text-sm text-muted-foreground" aria-label="Breadcrumb">
<nav
className="text-muted-foreground flex items-center text-sm"
aria-label="Breadcrumb"
>
{crumbs.map((crumb, i) => (
<span key={crumb.href} className="flex items-center">
{i > 0 && <ChevronRight className="mx-2 h-4 w-4 text-gray-300" />}
{i < crumbs.length - 1 ? (
<Link href={crumb.href} className="hover:underline text-gray-500">
<Link href={crumb.href} className="text-gray-500 hover:underline">
{crumb.name}
</Link>
) : (
@@ -29,4 +32,4 @@ export function Breadcrumbs() {
))}
</nav>
);
}
}
@@ -53,7 +53,7 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
(_, i) => (
<div
key={i}
className="flex items-center gap-3 px-3 py-2.5"
className="flex items-center gap-3 px-3 py-2.5"
>
<Skeleton className="bg-muted/20 h-4 w-4" />
<Skeleton className="bg-muted/20 h-4 w-20" />
@@ -71,10 +71,11 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
aria-current={
pathname === link.href ? "page" : undefined
}
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${pathname === link.href
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-muted"
}`}
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${
pathname === link.href
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-muted"
}`}
onClick={onToggle}
>
<Icon className="h-4 w-4" />
@@ -205,9 +205,9 @@ export function AnimationPreferencesProvider({
if (typeof window === "undefined") return;
const stored = readLocalStorage();
const systemReduced =
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const systemReduced = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
).matches;
const finalPrefers =
stored?.prefersReducedMotion ??
@@ -216,10 +216,11 @@ export function AnimationPreferencesProvider({
DEFAULT_PREFERS_REDUCED;
const finalSpeed = clampSpeed(
stored?.animationSpeedMultiplier ??
initial?.animationSpeedMultiplier ??
DEFAULT_SPEED,
initial?.animationSpeedMultiplier ??
DEFAULT_SPEED,
);
// eslint-disable-next-line react-hooks/set-state-in-effect -- Hydrate preferences from localStorage/system settings on mount.
setPrefersReducedMotion(finalPrefers);
setAnimationSpeedMultiplier(finalSpeed);
applyPreferencesToDOM({
@@ -279,7 +280,8 @@ export function AnimationPreferencesProvider({
// Optionally sync to server
const shouldSync = opts?.sync ?? autoSync;
if (shouldSync && serverPrefs) { // If serverPrefs exists, user is authenticated
if (shouldSync && serverPrefs) {
// If serverPrefs exists, user is authenticated
pendingSyncRef.current = {
prefersReducedMotion: patch.prefersReducedMotion,
animationSpeedMultiplier: patch.animationSpeedMultiplier,
@@ -334,6 +336,7 @@ export function AnimationPreferencesProvider({
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reconcile loaded server preferences once after query hydration.
performUpdate(
{
prefersReducedMotion: serverPrefs.prefersReducedMotion,
@@ -402,9 +405,15 @@ export function useAnimationPreferences(): AnimationPreferencesContextValue {
return {
prefersReducedMotion: false,
animationSpeedMultiplier: 1,
updatePreferences: () => { /* no-op fallback */ },
setPrefersReducedMotion: () => { /* no-op fallback */ },
setAnimationSpeedMultiplier: () => { /* no-op fallback */ },
updatePreferences: () => {
/* no-op fallback */
},
setPrefersReducedMotion: () => {
/* no-op fallback */
},
setAnimationSpeedMultiplier: () => {
/* no-op fallback */
},
isUpdating: false,
lastSyncedAt: null,
};
+118 -93
View File
@@ -6,10 +6,22 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
defaultFontPreference,
fallbackAppearance,
isColorMode,
isColorTheme,
isFontPreference,
isHslChannels,
isInterfaceTheme,
isPdfTemplate,
isRadiusPreference,
isSidebarStyle,
type PdfTemplate,
} from "~/lib/appearance";
import {
defaultBodyFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
@@ -27,7 +39,6 @@ import { api } from "~/trpc/react";
type AppearancePreferences = {
interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
@@ -39,7 +50,7 @@ type AppearancePreferences = {
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: "classic" | "minimal";
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
@@ -50,7 +61,6 @@ type AppearancePatch = Partial<AppearancePreferences>;
type ServerAppearance = {
interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
@@ -62,7 +72,7 @@ type ServerAppearance = {
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: "classic" | "minimal";
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
@@ -71,6 +81,7 @@ type ServerAppearance = {
type AppearanceContextValue = AppearancePreferences & {
updateAppearance: (patch: AppearancePatch) => void;
updateAppearanceDebounced: (patch: AppearancePatch) => void;
isUpdating: boolean;
};
@@ -78,22 +89,21 @@ const STORAGE_KEY = "bv.appearance";
const defaultAppearance: AppearancePreferences = {
interfaceTheme: defaultInterfaceTheme,
fontPreference: defaultFontPreference,
bodyFontPreference: defaultBodyFontPreference,
headingFontPreference: defaultHeadingFontPreference,
radiusPreference: defaultRadiusPreference,
sidebarStyle: defaultSidebarStyle,
colorMode: "system",
colorTheme: "slate",
colorMode: fallbackAppearance.colorMode,
colorTheme: fallbackAppearance.colorTheme,
brandName: defaultBrand.name,
brandTagline: defaultBrand.tagline,
brandLogoText: defaultBrand.logoText,
brandIcon: defaultBrand.icon,
pdfTemplate: "classic",
pdfAccentColor: "#111827",
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
pdfTemplate: fallbackAppearance.pdfTemplate,
pdfAccentColor: fallbackAppearance.pdfAccentColor,
pdfFooterText: fallbackAppearance.pdfFooterText,
pdfShowLogo: fallbackAppearance.pdfShowLogo,
pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
};
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
@@ -103,7 +113,6 @@ function getServerAppearancePatch(
): AppearancePatch {
return {
interfaceTheme: serverAppearance.interfaceTheme,
fontPreference: serverAppearance.fontPreference,
bodyFontPreference: serverAppearance.bodyFontPreference,
headingFontPreference: serverAppearance.headingFontPreference,
radiusPreference: serverAppearance.radiusPreference,
@@ -123,53 +132,6 @@ function getServerAppearancePatch(
};
}
function isInterfaceTheme(value: unknown): value is InterfaceTheme {
return (
value === "beenvoice" ||
value === "shadcn" ||
value === "minimal" ||
value === "editorial"
);
}
function isFontPreference(value: unknown): value is FontPreference {
return (
value === "brand" ||
value === "platform" ||
value === "inter" ||
value === "serif"
);
}
function isColorMode(value: unknown): value is ColorMode {
return value === "light" || value === "dark" || value === "system";
}
function isColorTheme(value: unknown): value is ColorTheme {
return (
value === "slate" ||
value === "blue" ||
value === "green" ||
value === "rose" ||
value === "orange" ||
value === "custom"
);
}
function isRadiusPreference(value: unknown): value is RadiusPreference {
return (
value === "none" ||
value === "sm" ||
value === "md" ||
value === "lg" ||
value === "xl"
);
}
function isSidebarStyle(value: unknown): value is SidebarStyle {
return value === "floating" || value === "docked";
}
function readStoredAppearance(): Partial<AppearancePreferences> | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
@@ -179,9 +141,6 @@ function readStoredAppearance(): Partial<AppearancePreferences> | null {
interfaceTheme: isInterfaceTheme(parsed.interfaceTheme)
? parsed.interfaceTheme
: undefined,
fontPreference: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
bodyFontPreference: isFontPreference(parsed.bodyFontPreference)
? parsed.bodyFontPreference
: isFontPreference(parsed.fontPreference)
@@ -202,8 +161,9 @@ function readStoredAppearance(): Partial<AppearancePreferences> | null {
colorTheme: isColorTheme(parsed.colorTheme)
? parsed.colorTheme
: undefined,
customColor:
typeof parsed.customColor === "string" ? parsed.customColor : undefined,
customColor: isHslChannels(parsed.customColor)
? parsed.customColor
: undefined,
brandName:
typeof parsed.brandName === "string" ? parsed.brandName : undefined,
brandTagline:
@@ -216,10 +176,9 @@ function readStoredAppearance(): Partial<AppearancePreferences> | null {
: undefined,
brandIcon:
typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined,
pdfTemplate:
parsed.pdfTemplate === "classic" || parsed.pdfTemplate === "minimal"
? parsed.pdfTemplate
: undefined,
pdfTemplate: isPdfTemplate(parsed.pdfTemplate)
? parsed.pdfTemplate
: undefined,
pdfAccentColor:
typeof parsed.pdfAccentColor === "string"
? parsed.pdfAccentColor
@@ -255,7 +214,6 @@ function applyAppearance(prefs: AppearancePreferences) {
const root = document.documentElement;
root.dataset.interfaceTheme = prefs.interfaceTheme;
root.dataset.font = prefs.fontPreference;
root.dataset.bodyFont = prefs.bodyFontPreference;
root.dataset.headingFont = prefs.headingFontPreference;
root.dataset.radius = prefs.radiusPreference;
@@ -279,6 +237,8 @@ export function AppearanceProvider({
}) {
const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
const utils = api.useUtils();
const updateMutation = api.settings.updateTheme.useMutation({
onSuccess: async () => {
@@ -299,6 +259,38 @@ export function AppearanceProvider({
},
});
const persistAppearance = useCallback(
(patch: AppearancePatch) => {
if (
patch.customColor !== undefined &&
!isHslChannels(patch.customColor)
) {
return;
}
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,
});
},
[updateMutation],
);
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
@@ -328,6 +320,15 @@ export function AppearanceProvider({
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);
@@ -335,37 +336,61 @@ export function AppearanceProvider({
return next;
});
updateMutation.mutate({
interfaceTheme: patch.interfaceTheme,
fontPreference: patch.fontPreference,
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,
});
persistAppearance(patch);
},
[updateMutation],
[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,
updateAppearance,
updateAppearanceDebounced,
isUpdating: updateMutation.isPending,
}),
[appearance, updateAppearance, updateMutation.isPending],
[
appearance,
updateAppearance,
updateAppearanceDebounced,
updateMutation.isPending,
],
);
return (
+21 -21
View File
@@ -1,15 +1,15 @@
"use client"
"use client";
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "~/lib/utils"
import { buttonVariants } from "~/components/ui/button"
import { cn } from "~/lib/utils";
import { buttonVariants } from "~/components/ui/button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
@@ -17,7 +17,7 @@ function AlertDialogTrigger({
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
);
}
function AlertDialogPortal({
@@ -25,7 +25,7 @@ function AlertDialogPortal({
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
);
}
function AlertDialogOverlay({
@@ -37,11 +37,11 @@ function AlertDialogOverlay({
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogContent({
@@ -54,13 +54,13 @@ function AlertDialogContent({
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
className
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
)
);
}
function AlertDialogHeader({
@@ -73,7 +73,7 @@ function AlertDialogHeader({
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
);
}
function AlertDialogFooter({
@@ -85,11 +85,11 @@ function AlertDialogFooter({
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogTitle({
@@ -102,7 +102,7 @@ function AlertDialogTitle({
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
);
}
function AlertDialogDescription({
@@ -115,7 +115,7 @@ function AlertDialogDescription({
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
function AlertDialogAction({
@@ -127,7 +127,7 @@ function AlertDialogAction({
className={cn(buttonVariants(), className)}
{...props}
/>
)
);
}
function AlertDialogCancel({
@@ -139,7 +139,7 @@ function AlertDialogCancel({
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
);
}
export {
@@ -154,4 +154,4 @@ export {
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
};
+38 -38
View File
@@ -1,50 +1,50 @@
"use client"
"use client";
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"bg-muted flex h-full w-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback }
export { Avatar, AvatarImage, AvatarFallback };
+15 -15
View File
@@ -1,11 +1,11 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
@@ -14,11 +14,11 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
className,
)}
{...props}
/>
)
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
@@ -28,7 +28,7 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
);
}
function BreadcrumbLink({
@@ -36,9 +36,9 @@ function BreadcrumbLink({
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a"
const Comp = asChild ? Slot : "a";
return (
<Comp
@@ -46,7 +46,7 @@ function BreadcrumbLink({
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
@@ -59,7 +59,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
);
}
function BreadcrumbSeparator({
@@ -77,7 +77,7 @@ function BreadcrumbSeparator({
>
{children ?? <ChevronRight />}
</li>
)
);
}
function BreadcrumbEllipsis({
@@ -95,7 +95,7 @@ function BreadcrumbEllipsis({
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
);
}
export {
@@ -106,4 +106,4 @@ export {
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
};
+1 -1
View File
@@ -36,7 +36,7 @@ const buttonVariants = cva(
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
+49 -43
View File
@@ -1,15 +1,19 @@
"use client"
"use client";
import * as React from "react"
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayPicker, getDefaultClassNames, type DayButton } from "react-day-picker"
} from "lucide-react";
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker";
import { cn } from "~/lib/utils"
import { Button, buttonVariants } from "~/components/ui/button"
import { cn } from "~/lib/utils";
import { Button, buttonVariants } from "~/components/ui/button";
function Calendar({
className,
@@ -21,9 +25,9 @@ function Calendar({
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames()
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
@@ -32,7 +36,7 @@ function Calendar({
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
className,
)}
captionLayout={captionLayout}
formatters={{
@@ -44,86 +48,88 @@ function Calendar({
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
defaultClassNames.months,
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
defaultClassNames.button_next,
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
defaultClassNames.month_caption,
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
defaultClassNames.dropdown_root,
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
defaultClassNames.dropdown,
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
defaultClassNames.weekday,
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
defaultClassNames.week_number_header,
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
defaultClassNames.week_number,
),
day: cn(
"relative w-full h-full p-0 text-center group/day aspect-square select-none",
props.mode !== "single" && "[&:last-child[data-selected=true]_button]:rounded-r-md",
props.mode !== "single" && (props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md"),
defaultClassNames.day
props.mode !== "single" &&
"[&:last-child[data-selected=true]_button]:rounded-r-md",
props.mode !== "single" &&
(props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md"),
defaultClassNames.day,
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
defaultClassNames.range_start,
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
defaultClassNames.today,
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
defaultClassNames.outside,
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
defaultClassNames.disabled,
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
@@ -137,13 +143,13 @@ function Calendar({
className={cn(className)}
{...props}
/>
)
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
);
}
if (orientation === "right") {
@@ -152,12 +158,12 @@ function Calendar({
className={cn("size-4", className)}
{...props}
/>
)
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
@@ -167,13 +173,13 @@ function Calendar({
{children}
</div>
</td>
)
);
},
...components,
}}
{...props}
/>
)
);
}
function CalendarDayButton({
@@ -182,12 +188,12 @@ function CalendarDayButton({
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null)
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
@@ -207,11 +213,11 @@ function CalendarDayButton({
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
className,
)}
{...props}
/>
)
);
}
export { Calendar, CalendarDayButton }
export { Calendar, CalendarDayButton };
+1 -1
View File
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-background/80 backdrop-blur-xl border-border/50 text-card-foreground flex flex-col rounded-3xl border shadow-sm overflow-hidden",
"bg-background/80 border-border/50 text-card-foreground flex flex-col overflow-hidden rounded-3xl border shadow-sm backdrop-blur-xl",
className,
)}
{...props}
+8 -8
View File
@@ -1,10 +1,10 @@
"use client"
"use client";
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Checkbox({
className,
@@ -15,7 +15,7 @@ function Checkbox({
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
className,
)}
{...props}
>
@@ -26,7 +26,7 @@ function Checkbox({
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
);
}
export { Checkbox }
export { Checkbox };
+6 -6
View File
@@ -1,11 +1,11 @@
"use client"
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
@@ -16,7 +16,7 @@ function CollapsibleTrigger({
data-slot="collapsible-trigger"
{...props}
/>
)
);
}
function CollapsibleContent({
@@ -27,7 +27,7 @@ function CollapsibleContent({
data-slot="collapsible-content"
{...props}
/>
)
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+18 -7
View File
@@ -3,13 +3,24 @@
import { motion, useSpring, useTransform } from "framer-motion";
import { useEffect } from "react";
export function CountUp({ value, prefix = "", suffix = "" }: { value: number, prefix?: string, suffix?: string }) {
const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 });
const display = useTransform(spring, (current) => `${prefix}${current.toFixed(2)}${suffix}`);
export function CountUp({
value,
prefix = "",
suffix = "",
}: {
value: number;
prefix?: string;
suffix?: string;
}) {
const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 });
const display = useTransform(
spring,
(current) => `${prefix}${current.toFixed(2)}${suffix}`,
);
useEffect(() => {
spring.set(value);
}, [spring, value]);
useEffect(() => {
spring.set(value);
}, [spring, value]);
return <motion.span>{display}</motion.span>;
return <motion.span>{display}</motion.span>;
}
+14 -5
View File
@@ -60,12 +60,13 @@ export function DatePicker({
const inputWidthClass = className?.includes("w-full")
? "w-full"
: className?.includes("w-32") ||
className?.includes("w-28") ||
className?.includes("w-36")
className?.includes("w-28") ||
className?.includes("w-36")
? className
: "w-full md:w-32 md:min-w-32";
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
setValue(formatDate(date));
setMonth(date);
}, [date]);
@@ -77,7 +78,12 @@ export function DatePicker({
value={value}
placeholder={placeholder}
disabled={disabled}
className={cn("bg-background pr-10", sizeClasses[size], "w-full", inputClassName)}
className={cn(
"bg-background pr-10",
sizeClasses[size],
"w-full",
inputClassName,
)}
onChange={(e) => {
setValue(e.target.value);
const parsedDate = parseDate(e.target.value);
@@ -98,13 +104,16 @@ export function DatePicker({
<Button
variant="ghost"
disabled={disabled}
className="absolute top-1/2 right-2 size-6 p-0 -translate-y-1/2 text-primary/80 hover:text-primary transition-colors z-20"
className="text-primary/80 hover:text-primary absolute top-1/2 right-2 z-20 size-6 -translate-y-1/2 p-0 transition-colors"
>
<CalendarIcon className="size-4" />
<span className="sr-only">Select date</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0 rounded-xl" align="end">
<PopoverContent
className="w-auto overflow-hidden rounded-xl p-0"
align="end"
>
<Calendar
mode="single"
selected={date}
+21 -21
View File
@@ -1,33 +1,33 @@
"use client"
"use client";
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
@@ -39,11 +39,11 @@ function DialogOverlay({
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
className,
)}
{...props}
/>
)
);
}
function DialogContent({
@@ -52,7 +52,7 @@ function DialogContent({
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
@@ -60,8 +60,8 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
className
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
@@ -77,7 +77,7 @@ function DialogContent({
)}
</DialogPrimitive.Content>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -87,7 +87,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -96,11 +96,11 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
className,
)}
{...props}
/>
)
);
}
function DialogTitle({
@@ -113,7 +113,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
);
}
function DialogDescription({
@@ -126,7 +126,7 @@ function DialogDescription({
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
export {
@@ -140,4 +140,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};
+6 -6
View File
@@ -42,7 +42,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md",
className,
)}
{...props}
@@ -74,7 +74,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-foreground-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-foreground-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-foreground-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground-foreground flex cursor-default items-center px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
"focus:bg-accent focus:text-foreground-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground-foreground flex cursor-default items-center px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
@@ -230,7 +230,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden border-0 shadow-lg",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden border-0 shadow-lg",
className,
)}
{...props}
+25 -25
View File
@@ -4,34 +4,34 @@ import { cn } from "~/lib/utils";
import { Skeleton } from "~/components/ui/skeleton";
interface ImageWithSkeletonProps extends ImageProps {
containerClassName?: string;
containerClassName?: string;
}
export function ImageWithSkeleton({
className,
containerClassName,
alt,
...props
className,
containerClassName,
alt,
...props
}: ImageWithSkeletonProps) {
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(true);
return (
<div className={cn("relative overflow-hidden", containerClassName)}>
{isLoading && (
<Skeleton className="absolute inset-0 h-full w-full animate-pulse" />
)}
<Image
className={cn(
"duration-700 ease-in-out",
isLoading
? "scale-110 blur-2xl grayscale"
: "scale-100 blur-0 grayscale-0",
className
)}
onLoad={() => setIsLoading(false)}
alt={alt}
{...props}
/>
</div>
);
return (
<div className={cn("relative overflow-hidden", containerClassName)}>
{isLoading && (
<Skeleton className="absolute inset-0 h-full w-full animate-pulse" />
)}
<Image
className={cn(
"duration-700 ease-in-out",
isLoading
? "scale-110 blur-2xl grayscale"
: "blur-0 scale-100 grayscale-0",
className,
)}
onLoad={() => setIsLoading(false)}
alt={alt}
{...props}
/>
</div>
);
}
+568
View File
@@ -0,0 +1,568 @@
"use client";
import { useEffect, useState } from "react";
import { HexAlphaColorPicker, HexColorPicker } from "react-colorful";
import { Loader2, PipetteIcon } from "lucide-react";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
hexToRgb,
hexToRgba,
hslToRgb,
hslaToRgba,
rgbToHex,
rgbToHsl,
rgbaToHex,
rgbaToHsla,
} from "~/lib/color-converter";
import { cn } from "~/lib/utils";
declare global {
interface Window {
EyeDropper?: new () => {
open: () => Promise<{ sRGBHex: string }>;
};
}
}
export const colorSchema = z
.string()
.regex(
/^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/,
"Color must be a valid hex color (e.g., #FF0000 or #FF0000FF)",
)
.transform((val) => val.toUpperCase());
interface ColorPickerProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
isLoading?: boolean;
label: string;
error?: string;
className?: string;
alpha?: boolean;
}
interface ColorValues {
hex: string;
rgb: { r: number; g: number; b: number };
hsl: { h: number; s: number; l: number };
rgba?: { r: number; g: number; b: number; a: number };
hsla?: { h: number; s: number; l: number; a: number };
}
export function InputColor({
value,
onChange,
onBlur = () => undefined,
isLoading = false,
label,
error,
className = "mt-6",
alpha = false,
}: ColorPickerProps) {
const [colorFormat, setColorFormat] = useState(alpha ? "HEXA" : "HEX");
const [colorValues, setColorValues] = useState<ColorValues>(() =>
getColorValues(value, alpha),
);
const [hexInputValue, setHexInputValue] = useState(value);
const [hexInputError, setHexInputError] = useState<string | null>(null);
const updateColorValues = (newColor: string) => {
const nextValues = getColorValues(newColor, alpha);
setColorValues(nextValues);
setHexInputValue(newColor.toUpperCase());
};
const handleColorChange = (newColor: string) => {
updateColorValues(newColor);
onChange(newColor.toUpperCase());
};
const handleHexChange = (nextValue: string) => {
let formattedValue = nextValue.toUpperCase();
if (!formattedValue.startsWith("#")) {
formattedValue = `#${formattedValue}`;
}
const maxLength = alpha ? 9 : 7;
if (
formattedValue.length <= maxLength &&
/^#[0-9A-Fa-f]*$/.test(formattedValue)
) {
setHexInputValue(formattedValue);
onChange(formattedValue);
updateColorValues(formattedValue);
try {
if (formattedValue.length === maxLength) {
colorSchema.parse(formattedValue);
setHexInputError(null);
} else {
setHexInputError("Enter a valid color");
}
} catch (validationError) {
if (validationError instanceof z.ZodError) {
setHexInputError("Enter a valid color");
}
}
}
};
const handleRgbChange = (component: "r" | "g" | "b", nextValue: string) => {
const numValue = Number.parseInt(nextValue) || 0;
const clampedValue = Math.max(0, Math.min(255, numValue));
const newRgb = { ...colorValues.rgb, [component]: clampedValue };
const hex = rgbToHex(newRgb.r, newRgb.g, newRgb.b);
const hsl = rgbToHsl(newRgb.r, newRgb.g, newRgb.b);
setColorValues({ ...colorValues, hex, rgb: newRgb, hsl });
setHexInputValue(hex);
onChange(hex);
};
const handleRgbaChange = (
component: "r" | "g" | "b" | "a",
nextValue: string,
) => {
if (!alpha || !colorValues.rgba) return;
const numValue = Number.parseFloat(nextValue) || 0;
const clampedValue =
component === "a"
? Math.max(0, Math.min(1, numValue))
: Math.max(0, Math.min(255, Math.floor(numValue)));
const newRgba = { ...colorValues.rgba, [component]: clampedValue };
const hex = rgbaToHex(newRgba.r, newRgba.g, newRgba.b, newRgba.a);
const hsla = rgbaToHsla(newRgba.r, newRgba.g, newRgba.b, newRgba.a);
setColorValues({
...colorValues,
hex: hex.slice(0, 7),
rgb: { r: newRgba.r, g: newRgba.g, b: newRgba.b },
hsl: rgbToHsl(newRgba.r, newRgba.g, newRgba.b),
rgba: newRgba,
hsla,
});
setHexInputValue(hex);
onChange(hex);
};
const handleHslChange = (component: "h" | "s" | "l", nextValue: string) => {
const numValue = Number.parseInt(nextValue) || 0;
const clampedValue =
component === "h"
? Math.max(0, Math.min(360, numValue))
: Math.max(0, Math.min(100, numValue));
const newHsl = { ...colorValues.hsl, [component]: clampedValue };
const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l);
const hex = rgbToHex(rgb.r, rgb.g, rgb.b);
setColorValues({ ...colorValues, hex, rgb, hsl: newHsl });
setHexInputValue(hex);
onChange(hex);
};
const handleHslaChange = (
component: "h" | "s" | "l" | "a",
nextValue: string,
) => {
if (!alpha || !colorValues.hsla) return;
const numValue = Number.parseFloat(nextValue) || 0;
const clampedValue =
component === "a"
? Math.max(0, Math.min(1, numValue))
: component === "h"
? Math.max(0, Math.min(360, numValue))
: Math.max(0, Math.min(100, numValue));
const newHsla = { ...colorValues.hsla, [component]: clampedValue };
const rgba = hslaToRgba(newHsla.h, newHsla.s, newHsla.l, newHsla.a);
const hex = rgbaToHex(rgba.r, rgba.g, rgba.b, rgba.a);
setColorValues({
...colorValues,
hex: hex.slice(0, 7),
rgb: { r: rgba.r, g: rgba.g, b: rgba.b },
hsl: { h: newHsla.h, s: newHsla.s, l: newHsla.l },
rgba,
hsla: newHsla,
});
setHexInputValue(hex);
onChange(hex);
};
const handlePopoverChange = (open: boolean) => {
if (!open) {
setColorFormat(alpha ? "HEXA" : "HEX");
onBlur();
}
};
const handleEyeDropper = async () => {
const EyeDropper = window.EyeDropper;
if (!EyeDropper) return;
try {
const eyeDropper = new EyeDropper();
const result = await eyeDropper.open();
const pickedColor = result.sRGBHex;
updateColorValues(pickedColor);
onChange(pickedColor);
} catch {
// User canceled the browser picker.
}
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Synchronize controlled color value into the picker fields.
updateColorValues(value);
setHexInputValue(value.toUpperCase());
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateColorValues intentionally derives all picker state from value.
}, [value]);
const getCurrentHexValue = () => {
if (colorFormat === "HEX" || colorFormat === "HEXA") {
return hexInputValue;
}
if (alpha && colorValues.rgba) {
return rgbaToHex(
colorValues.rgba.r,
colorValues.rgba.g,
colorValues.rgba.b,
colorValues.rgba.a,
);
}
return colorValues.hex;
};
return (
<div className={cn(className)}>
<Label className="mb-3">{label}</Label>
<div className="flex items-center gap-4">
<Popover onOpenChange={handlePopoverChange}>
<PopoverTrigger asChild>
<Button
className="border-border relative h-12 w-12 overflow-hidden border shadow-none"
size="icon"
style={{ backgroundColor: hexInputValue }}
type="button"
variant="outline"
>
{alpha && colorValues.rgba && colorValues.rgba.a < 1 && (
<span
className="absolute inset-0 opacity-20"
style={{
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
backgroundSize: "8px 8px",
backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
}}
/>
)}
<span className="sr-only">Open {label} picker</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-3" align="start">
<div className="color-picker space-y-3">
<div className="relative">
<Button
variant="ghost"
size="icon"
className="absolute -top-1.5 -left-1 z-10 flex h-7 w-7 items-center gap-1 bg-transparent hover:bg-transparent"
onClick={handleEyeDropper}
disabled={!isEyeDropperAvailable()}
type="button"
>
<PipetteIcon className="h-3 w-3" />
<span className="sr-only">Pick color from screen</span>
</Button>
{alpha ? (
<HexAlphaColorPicker
className="!aspect-square !h-[244.79px] !w-[244.79px]"
color={value}
onChange={handleColorChange}
/>
) : (
<HexColorPicker
className="!aspect-square !h-[244.79px] !w-[244.79px]"
color={value}
onChange={handleColorChange}
/>
)}
</div>
<div className="flex gap-2">
<Select value={colorFormat} onValueChange={setColorFormat}>
<SelectTrigger className="!h-7 !w-[4.8rem] rounded-sm px-2 py-1 !text-sm">
<SelectValue placeholder="Color" />
</SelectTrigger>
<SelectContent className="min-w-20">
{alpha ? (
<>
<SelectItem value="HEXA" className="h-7 text-sm">
HEXA
</SelectItem>
<SelectItem value="RGBA" className="h-7 text-sm">
RGBA
</SelectItem>
<SelectItem value="HSLA" className="h-7 text-sm">
HSLA
</SelectItem>
</>
) : (
<>
<SelectItem value="HEX" className="h-7 text-sm">
HEX
</SelectItem>
<SelectItem value="RGB" className="h-7 text-sm">
RGB
</SelectItem>
<SelectItem value="HSL" className="h-7 text-sm">
HSL
</SelectItem>
</>
)}
</SelectContent>
</Select>
<ColorFormatFields
alpha={alpha}
colorFormat={colorFormat}
colorValues={colorValues}
currentHexValue={getCurrentHexValue()}
handleHexChange={handleHexChange}
handleHslChange={handleHslChange}
handleHslaChange={handleHslaChange}
handleRgbChange={handleRgbChange}
handleRgbaChange={handleRgbaChange}
/>
</div>
</div>
</PopoverContent>
</Popover>
<div className="relative flex-1 sm:flex-none">
<Input
placeholder={label}
value={getCurrentHexValue()}
onChange={(event) => handleHexChange(event.target.value)}
onBlur={onBlur}
className={cn("h-12 uppercase", error && "border-destructive")}
/>
{isLoading && (
<span className="absolute inset-y-0 right-0 flex items-center pr-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</span>
)}
</div>
</div>
{error && <p className="text-destructive mt-1.5 text-sm">{error}</p>}
{hexInputError && (
<p className="text-destructive mt-1.5 text-sm">{hexInputError}</p>
)}
</div>
);
}
function ColorFormatFields({
alpha,
colorFormat,
colorValues,
currentHexValue,
handleHexChange,
handleRgbChange,
handleRgbaChange,
handleHslChange,
handleHslaChange,
}: {
alpha: boolean;
colorFormat: string;
colorValues: ColorValues;
currentHexValue: string;
handleHexChange: (value: string) => void;
handleRgbChange: (component: "r" | "g" | "b", value: string) => void;
handleRgbaChange: (component: "r" | "g" | "b" | "a", value: string) => void;
handleHslChange: (component: "h" | "s" | "l", value: string) => void;
handleHslaChange: (component: "h" | "s" | "l" | "a", value: string) => void;
}) {
if (colorFormat === "HEX" || colorFormat === "HEXA") {
return (
<Input
className="h-7 w-[160px] rounded-sm text-sm"
value={currentHexValue}
onChange={(event) => handleHexChange(event.target.value)}
placeholder={alpha ? "#FF0000FF" : "#FF0000"}
maxLength={alpha ? 9 : 7}
/>
);
}
if (colorFormat === "RGB") {
return (
<div className="flex items-center">
<Input
className="h-7 w-13 rounded-l-sm rounded-r-none text-center text-sm"
value={colorValues.rgb.r}
onChange={(event) => handleRgbChange("r", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-none border-x-0 text-center text-sm"
value={colorValues.rgb.g}
onChange={(event) => handleRgbChange("g", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-l-none rounded-r-sm text-center text-sm"
value={colorValues.rgb.b}
onChange={(event) => handleRgbChange("b", event.target.value)}
placeholder="255"
maxLength={3}
/>
</div>
);
}
if (colorFormat === "RGBA" && alpha && colorValues.rgba) {
return (
<div className="flex items-center">
<Input
className="h-7 w-10 rounded-l-sm rounded-r-none px-1 text-center text-sm"
value={colorValues.rgba.r}
onChange={(event) => handleRgbaChange("r", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.rgba.g}
onChange={(event) => handleRgbaChange("g", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.rgba.b}
onChange={(event) => handleRgbaChange("b", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-l-none rounded-r-sm px-1 text-center text-sm"
value={colorValues.rgba.a.toFixed(2)}
onChange={(event) => handleRgbaChange("a", event.target.value)}
placeholder="1.00"
maxLength={4}
/>
</div>
);
}
if (colorFormat === "HSL") {
return (
<div className="flex items-center">
<Input
className="h-7 w-13 rounded-l-sm rounded-r-none text-center text-sm"
value={colorValues.hsl.h}
onChange={(event) => handleHslChange("h", event.target.value)}
placeholder="360"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-none border-x-0 text-center text-sm"
value={colorValues.hsl.s}
onChange={(event) => handleHslChange("s", event.target.value)}
placeholder="100"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-l-none rounded-r-sm text-center text-sm"
value={colorValues.hsl.l}
onChange={(event) => handleHslChange("l", event.target.value)}
placeholder="100"
maxLength={3}
/>
</div>
);
}
if (colorFormat === "HSLA" && alpha && colorValues.hsla) {
return (
<div className="flex items-center">
<Input
className="h-7 w-10 rounded-l-sm rounded-r-none px-1 text-center text-sm"
value={colorValues.hsla.h}
onChange={(event) => handleHslaChange("h", event.target.value)}
placeholder="360"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.hsla.s}
onChange={(event) => handleHslaChange("s", event.target.value)}
placeholder="100"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.hsla.l}
onChange={(event) => handleHslaChange("l", event.target.value)}
placeholder="100"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-l-none rounded-r-sm px-1 text-center text-sm"
value={colorValues.hsla.a.toFixed(2)}
onChange={(event) => handleHslaChange("a", event.target.value)}
placeholder="1.00"
maxLength={4}
/>
</div>
);
}
return null;
}
function getColorValues(value: string, alpha: boolean): ColorValues {
if (alpha) {
const rgba = hexToRgba(value);
const hsla = rgbaToHsla(rgba.r, rgba.g, rgba.b, rgba.a);
return {
hex: value.length === 9 ? value.slice(0, 7) : value,
rgb: { r: rgba.r, g: rgba.g, b: rgba.b },
hsl: rgbToHsl(rgba.r, rgba.g, rgba.b),
rgba,
hsla,
};
}
const rgb = hexToRgb(value);
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
return {
hex: value.toUpperCase(),
rgb,
hsl,
};
}
function isEyeDropperAvailable() {
return typeof window !== "undefined" && Boolean(window.EyeDropper);
}
+7 -7
View File
@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Label({
className,
@@ -14,11 +14,11 @@ function Label({
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
className,
)}
{...props}
/>
)
);
}
export { Label }
export { Label };
+27 -27
View File
@@ -1,9 +1,9 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function NavigationMenu({
className,
@@ -11,7 +11,7 @@ function NavigationMenu({
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
@@ -19,14 +19,14 @@ function NavigationMenu({
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
className,
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
);
}
function NavigationMenuList({
@@ -38,11 +38,11 @@ function NavigationMenuList({
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
className,
)}
{...props}
/>
)
);
}
function NavigationMenuItem({
@@ -55,12 +55,12 @@ function NavigationMenuItem({
className={cn("relative", className)}
{...props}
/>
)
);
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-foreground-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
"group inline-flex h-9 w-max items-center justify-center bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-foreground-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
);
function NavigationMenuTrigger({
className,
@@ -79,7 +79,7 @@ function NavigationMenuTrigger({
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
);
}
function NavigationMenuContent({
@@ -91,12 +91,12 @@ function NavigationMenuContent({
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu: group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu: group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className,
)}
{...props}
/>
)
);
}
function NavigationMenuViewport({
@@ -106,19 +106,19 @@ function NavigationMenuViewport({
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
"absolute top-full left-0 isolate z-50 flex justify-center",
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
{...props}
/>
</div>
)
);
}
function NavigationMenuLink({
@@ -129,12 +129,12 @@ function NavigationMenuLink({
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-foreground-foreground hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-foreground-foreground hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
);
}
function NavigationMenuIndicator({
@@ -146,13 +146,13 @@ function NavigationMenuIndicator({
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
);
}
export {
@@ -165,4 +165,4 @@ export {
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
};
+11 -11
View File
@@ -1,20 +1,20 @@
"use client"
"use client";
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
@@ -30,19 +30,19 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) border p-4 shadow-md outline-hidden",
className
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+1 -1
View File
@@ -14,7 +14,7 @@ function Progress({
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden ",
"bg-primary/20 relative h-2 w-full overflow-hidden",
className,
)}
{...props}
+11 -11
View File
@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
@@ -11,21 +11,21 @@ const Separator = React.forwardRef<
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
"bg-border shrink-0",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
className,
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator }
export { Separator };
+19 -19
View File
@@ -1,31 +1,31 @@
"use client"
"use client";
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
@@ -37,11 +37,11 @@ function SheetOverlay({
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
className,
)}
{...props}
/>
)
);
}
function SheetContent({
@@ -50,7 +50,7 @@ function SheetContent({
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
@@ -67,7 +67,7 @@ function SheetContent({
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
className,
)}
{...props}
>
@@ -78,7 +78,7 @@ function SheetContent({
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -88,7 +88,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -98,7 +98,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
);
}
function SheetTitle({
@@ -111,7 +111,7 @@ function SheetTitle({
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
);
}
function SheetDescription({
@@ -124,7 +124,7 @@ function SheetDescription({
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
export {
@@ -136,4 +136,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
}
};
+10 -21
View File
@@ -4,12 +4,7 @@ function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("bg-muted animate-pulse ", className)}
{...props}
/>
);
return <div className={cn("bg-muted animate-pulse", className)} {...props} />;
}
// Modern dashboard skeleton components
@@ -17,12 +12,9 @@ export function DashboardStatsSkeleton() {
return (
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className=" border border-gray-100 bg-white p-6 shadow-sm"
>
<div key={i} className="border border-gray-100 bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<Skeleton className="h-9 w-9 " />
<Skeleton className="h-9 w-9" />
<Skeleton className="h-4 w-12" />
</div>
<div>
@@ -39,10 +31,7 @@ export function DashboardCardsSkeleton() {
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<div
key={i}
className=" border border-gray-100 bg-white p-6 shadow-sm"
>
<div key={i} className="border border-gray-100 bg-white p-6 shadow-sm">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded" />
@@ -69,7 +58,7 @@ export function DashboardCardsSkeleton() {
export function DashboardActivitySkeleton() {
return (
<div className=" border border-gray-100 bg-white p-6 shadow-sm">
<div className="border border-gray-100 bg-white p-6 shadow-sm">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded" />
@@ -81,17 +70,17 @@ export function DashboardActivitySkeleton() {
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="flex items-center justify-between border border-gray-100 p-4"
className="flex items-center justify-between border border-gray-100 p-4"
>
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 " />
<Skeleton className="h-8 w-8" />
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-6 w-16 " />
<Skeleton className="h-6 w-16" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-8 w-8 rounded" />
</div>
@@ -115,14 +104,14 @@ export function DashboardHeroSkeleton() {
export function QuickActionsSkeleton() {
return (
<div className=" border border-gray-100 bg-white p-6 shadow-sm">
<div className="border border-gray-100 bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-6 w-32" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className=" border border-gray-200 p-4">
<div key={i} className="border border-gray-200 p-4">
<div className="flex items-center gap-3">
<Skeleton className="h-5 w-5" />
<div className="space-y-2">
+1
View File
@@ -113,6 +113,7 @@ export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
if (lockValue !== null) {
// Only update internal & emit if changed
if (!isControlled && internal !== 1) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Force the uncontrolled slider to the reduced-motion lock value.
setInternal(1);
}
if (lastEmittedRef.current !== 1) {
+4 -4
View File
@@ -11,7 +11,7 @@ const Tabs = React.forwardRef<
>(({ className, ...props }, ref) => (
<TabsPrimitive.Root
ref={ref}
className={cn("flex flex-col gap-2", className)}
className={cn("flex flex-col gap-1", className)}
{...props}
/>
));
@@ -24,7 +24,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg p-1",
"bg-muted text-muted-foreground flex h-9 w-full items-center justify-center rounded-lg p-1",
className,
)}
{...props}
@@ -39,7 +39,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow",
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex flex-1 items-center justify-center rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow",
className,
)}
{...props}
@@ -54,7 +54,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
className,
)}
{...props}
+10 -10
View File
@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function TooltipProvider({
delayDuration = 0,
@@ -15,7 +15,7 @@ function TooltipProvider({
delayDuration={delayDuration}
{...props}
/>
)
);
}
function Tooltip({
@@ -25,13 +25,13 @@ function Tooltip({
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
@@ -47,7 +47,7 @@ function TooltipContent({
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
className,
)}
{...props}
>
@@ -55,7 +55,7 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };