Archived
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ const buttonVariants = cva(
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user