From 59adaaf28114778b09c2297709e04c6b530eef99 Mon Sep 17 00:00:00 2001 From: i701 Date: Thu, 26 Jun 2025 18:42:48 +0500 Subject: [PATCH] =?UTF-8?q?feat(devices):=20add=20proper=20filter=20handli?= =?UTF-8?q?ng=20and=20update=20shadcn=20=F0=9F=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/devices/page.tsx | 5 +- app/globals.css | 52 +- components.json | 2 +- components/auth/application-layout.tsx | 7 +- components/devices-table.tsx | 48 +- components/devices/device-filter.tsx | 183 + components/search.tsx | 2 +- components/ui/accordion.tsx | 97 +- components/ui/alert.tsx | 59 + components/ui/aspect-ratio.tsx | 7 + components/ui/avatar.tsx | 50 + components/ui/badge.tsx | 4 +- components/ui/button.tsx | 62 +- components/ui/calendar.tsx | 272 +- components/ui/carousel.tsx | 262 + components/ui/chart.tsx | 365 ++ components/ui/context-menu.tsx | 200 + components/ui/dropdown-menu.tsx | 8 +- components/ui/form.tsx | 8 +- components/ui/hover-card.tsx | 29 + components/ui/input-otp.tsx | 71 + components/ui/input.tsx | 31 +- components/ui/menubar.tsx | 256 + components/ui/navigation-menu.tsx | 128 + components/ui/pagination.tsx | 117 + components/ui/popover.tsx | 2 +- components/ui/progress.tsx | 28 + components/ui/radio-group.tsx | 44 + components/ui/resizable.tsx | 45 + components/ui/select.tsx | 4 +- components/ui/sheet.tsx | 10 +- components/ui/sidebar.tsx | 1344 +++--- components/ui/slider.tsx | 28 + components/ui/sonner.tsx | 31 + components/ui/switch.tsx | 29 + components/ui/tabs.tsx | 55 + components/ui/toggle-group.tsx | 61 + components/ui/toggle.tsx | 45 + components/ui/tooltip.tsx | 2 +- lib/utils.ts | 54 +- middleware.ts | 2 +- package-lock.json | 40 + package.json | 62 +- queries/devices.ts | 14 +- tailwind.config.ts | 181 +- yarn.lock | 6121 ++++++++++++++++++++++++ 46 files changed, 9472 insertions(+), 1055 deletions(-) create mode 100644 components/devices/device-filter.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/aspect-ratio.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/chart.tsx create mode 100644 components/ui/context-menu.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 components/ui/menubar.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/resizable.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 yarn.lock diff --git a/app/(dashboard)/devices/page.tsx b/app/(dashboard)/devices/page.tsx index 8874727..b29e715 100644 --- a/app/(dashboard)/devices/page.tsx +++ b/app/(dashboard)/devices/page.tsx @@ -1,5 +1,6 @@ import { authOptions } from "@/app/auth"; import { DevicesTable } from "@/components/devices-table"; +import DeviceFilter from "@/components/devices/device-filter"; import Search from "@/components/search"; import AddDeviceDialogForm from "@/components/user/add-device-dialog"; import { getServerSession } from "next-auth"; @@ -27,9 +28,9 @@ export default async function Devices({
- +
}> diff --git a/app/globals.css b/app/globals.css index 01b8e1e..37c34a7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,31 +2,27 @@ @tailwind components; @tailwind utilities; -body { - font-family: Arial, Helvetica, sans-serif; -} - @layer base { :root { --background: 0 0% 100%; - --foreground: 240 10% 3.9%; + --foreground: 0 0% 3.9%; --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; + --card-foreground: 0 0% 3.9%; --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 240 5.9% 10%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 240 10% 3.9%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; @@ -43,25 +39,25 @@ body { --sidebar-ring: 217.2 91.2% 59.8%; } .dark { - --background: 240 10% 3.9%; + --background: 0 0% 3.9%; --foreground: 0 0% 98%; - --card: 240 10% 3.9%; + --card: 0 0% 3.9%; --card-foreground: 0 0% 98%; - --popover: 240 10% 3.9%; + --popover: 0 0% 3.9%; --popover-foreground: 0 0% 98%; --primary: 0 0% 98%; - --primary-foreground: 240 5.9% 10%; - --secondary: 240 3.7% 15.9%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; --secondary-foreground: 0 0% 98%; - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - --accent: 240 3.7% 15.9%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 240 4.9% 83.9%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; --chart-3: 30 80% 55%; diff --git a/components.json b/components.json index a312865..dea737b 100644 --- a/components.json +++ b/components.json @@ -6,7 +6,7 @@ "tailwind": { "config": "tailwind.config.ts", "css": "app/globals.css", - "baseColor": "zinc", + "baseColor": "neutral", "cssVariables": true, "prefix": "" }, diff --git a/components/auth/application-layout.tsx b/components/auth/application-layout.tsx index 82e97e8..573ccfc 100644 --- a/components/auth/application-layout.tsx +++ b/components/auth/application-layout.tsx @@ -1,5 +1,6 @@ import { DeviceCartDrawer } from "@/components/device-cart"; import { Wallet } from "@/components/wallet"; +import { NuqsAdapter } from 'nuqs/adapters/next/app' import { ModeToggle } from "@/components/theme-toggle"; import { AppSidebar } from "@/components/ui/app-sidebar"; @@ -15,8 +16,8 @@ import { import { tryCatch } from "@/utils/tryCatch"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; -import { AccountPopover } from "./account-popver"; import { WelcomeBanner } from "../welcome-banner"; +import { AccountPopover } from "./account-popver"; export async function ApplicationLayout({ children, @@ -51,7 +52,9 @@ export async function ApplicationLayout({ />
- {children} + + {children} +
diff --git a/components/devices-table.tsx b/components/devices-table.tsx index 410b5d4..123c113 100644 --- a/components/devices-table.tsx +++ b/components/devices-table.tsx @@ -23,21 +23,30 @@ export async function DevicesTable({ parentalControl, }: { searchParams: Promise<{ - query: string; - page: number; + [key: string]: unknown; }>; parentalControl?: boolean; }) { + const resolvedParams = await searchParams; const session = await getServerSession(authOptions); const isAdmin = session?.user?.is_superuser; - const query = (await searchParams)?.query || ""; - const page = (await searchParams)?.page || 1; - const limit = 10; // Items per page - const offset = (page - 1) * limit; // Calculate offset based on page + const page = Number.parseInt(resolvedParams.page as string) || 1; + const limit = 10; + const offset = (page - 1) * limit; + + // Build params object for getDevices + const apiParams: Record = {}; + for (const [key, value] of Object.entries(resolvedParams)) { + if (value !== undefined && value !== "") { + apiParams[key] = typeof value === "number" ? value : String(value); + } + } + apiParams.limit = limit; + apiParams.offset = offset; const [error, devices] = await tryCatch( - getDevices({ query: query, limit: limit, offset: offset }), + getDevices(apiParams), ); if (error) { if (error.message === "UNAUTHORIZED") { @@ -50,8 +59,8 @@ export async function DevicesTable({ return (
{data?.length === 0 ? ( -
-

No devices yet.

+
+

No devices.

) : ( <> @@ -78,17 +87,17 @@ export async function DevicesTable({ - - {query?.length > 0 && ( -

- Showing {meta?.total} devices for "{query} - " + + {meta?.total === 1 ? ( +

+ Total {meta?.total} device. +

+ ) : ( +

+ Total {meta?.total} devices.

)}
- - {meta?.total} devices -
@@ -107,7 +116,8 @@ export async function DevicesTable({ currentPage={meta?.current_page} /> - )} -
+ ) + } +
); } diff --git a/components/devices/device-filter.tsx b/components/devices/device-filter.tsx new file mode 100644 index 0000000..df34188 --- /dev/null +++ b/components/devices/device-filter.tsx @@ -0,0 +1,183 @@ +"use client" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button"; +import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { ListFilter, Loader2, X } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useQueryState } from "nuqs"; +import { useState, useTransition } from 'react'; +export default function DeviceFilter() { + const { replace } = useRouter(); + + const [isOpen, setIsOpen] = useState(false); + const [disabled, startTransition] = useTransition(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + const params = new URLSearchParams(searchParams.toString()); + + + const [urlInputName] = useQueryState("name", { + clearOnDefault: true + }) + const [urlInputMac] = useQueryState("mac", { + clearOnDefault: true + }) + const [urlInputVendor] = useQueryState("vendor", { + clearOnDefault: true + }) + + // Local state for input fields + const [inputName, setInputName] = useState(urlInputName ?? ""); + const [inputMac, setInputMac] = useState(urlInputMac ?? ""); + const [inputVendor, setInputVendor] = useState(urlInputVendor ?? ""); + + // Map filter keys to their state setters + const filterSetters: Record>> = { + name: setInputName, + mac: setInputMac, + vendor: setInputVendor, + }; + function handleSearch({ name, mac, vendor }: { name: string; mac: string; vendor: string }) { + + if (name) params.set("name", name); else params.delete("name"); + if (mac) params.set("mac", mac); else params.delete("mac"); + if (vendor) params.set("vendor", vendor); else params.delete("vendor"); + params.set("page", "1"); + + startTransition(() => { + replace(`${pathname}?${params.toString()}`); + }); + } + + const appliedFilters = searchParams + .toString() + .split("&") + .filter((filter) => !filter.startsWith("page=") && filter) + return ( +
+ + + + + +
+ + Device Filters + +
+ Select your desired filters here +
+
+
+ +
+ setInputName(e.target.value)} + /> + setInputMac(e.target.value)} + /> + setInputVendor(e.target.value)} + /> +
+ + + + + + + + +
+
+
+
+ {appliedFilters.map((filter) => ( + + {prettyPrintFilter(filter)} + { + disabled ? ( + + ) : ( + { + const key = filter.split("=")[0]; + params.delete(key); + + // Use the mapping to clear the correct input state + filterSetters[key]?.(""); + + startTransition(() => { + replace(`${pathname}?${params.toString()}`); + }); + }} + > + Remove + + ) + } + + ))} +
+
+ ); +} + + +function prettyPrintFilter(filter: string) { + const [key, value] = filter.split("="); + switch (key) { + case "name": + return

Device Name: {value}

; + case "mac": + return

MAC Address: {value}

; + case "vendor": + return

Vendor: {value}

; + default: + return filter; + } +} + diff --git a/components/search.tsx b/components/search.tsx index 21c7a66..e0caa5f 100644 --- a/components/search.tsx +++ b/components/search.tsx @@ -34,7 +34,7 @@ export default function Search({ disabled }: { disabled?: boolean }) { ref={inputRef} placeholder="Search..." type="search" - className={cn("w-fit", isPending && "animate-pulse")} + className={cn("sm:w-fit", isPending && "animate-pulse")} name="search" id="search" defaultValue={searchQuery ? searchQuery : ""} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx index 4a8cca4..2f55a32 100644 --- a/components/ui/accordion.tsx +++ b/components/ui/accordion.tsx @@ -2,65 +2,56 @@ import * as React from "react" import * as AccordionPrimitive from "@radix-ui/react-accordion" -import { ChevronDownIcon } from "lucide-react" +import { ChevronDown } from "lucide-react" import { cn } from "@/lib/utils" -function Accordion({ - ...props -}: React.ComponentProps) { - return -} +const Accordion = AccordionPrimitive.Root -function AccordionItem({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" -function AccordionTrigger({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - svg]:rotate-180", - className - )} - {...props} - > - {children} - - - - ) -} - -function AccordionContent({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - , + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} {...props} > -
{children}
-
- ) -} + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..5afd41d --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..d6a5226 --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx index e87d62b..b21ec24 100644 --- a/components/ui/badge.tsx +++ b/components/ui/badge.tsx @@ -1,5 +1,5 @@ +import { type VariantProps, cva } from "class-variance-authority" import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" @@ -25,7 +25,7 @@ const badgeVariants = cva( export interface BadgeProps extends React.HTMLAttributes, - VariantProps {} + VariantProps { } function Badge({ className, variant, ...props }: BadgeProps) { return ( diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 65d4fcd..12ae757 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -1,30 +1,31 @@ -import * as React from "react" import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import { type VariantProps, cva } from "class-variance-authority" +import * as React from "react" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { default: - "bg-primary text-primary-foreground shadow hover:bg-primary/90", + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: - "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: - "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: - "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { - default: "h-9 px-4 py-2", - sm: "h-8 rounded-md px-3 text-xs", - lg: "h-10 rounded-md px-8", - icon: "h-9 w-9", + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", }, }, defaultVariants: { @@ -34,24 +35,25 @@ const buttonVariants = cva( } ) -export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) } -const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" - return ( - - ) - } -) -Button.displayName = "Button" - export { Button, buttonVariants } diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx index b009680..22d7b57 100644 --- a/components/ui/calendar.tsx +++ b/components/ui/calendar.tsx @@ -1,72 +1,210 @@ -"use client"; +"use client" -import { ChevronLeft, ChevronRight } from "lucide-react"; -import { DayPicker } from "react-day-picker"; +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker" -import { buttonVariants } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; - -export type CalendarProps = React.ComponentProps; +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" function Calendar({ - className, - classNames, - showOutsideDays = true, - ...props -}: CalendarProps) { - return ( - .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" - : "[&:has([aria-selected])]:rounded-md", - ), - day: cn( - buttonVariants({ variant: "ghost" }), - "h-8 w-8 p-0 font-normal aria-selected:opacity-100", - ), - day_range_start: "day-range-start", - day_range_end: "day-range-end", - day_selected: - "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", - day_today: "bg-accent text-accent-foreground", - day_outside: - "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", - day_disabled: "text-muted-foreground opacity-50", - day_range_middle: - "aria-selected:bg-accent aria-selected:text-accent-foreground", - day_hidden: "invisible", - ...classNames, - }} - components={{ - // @ts-expect-error this works but types are not correct - IconLeft: () => , - IconRight: () => , - }} - {...props} - /> - ); -} -Calendar.displayName = "Calendar"; + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() -export { Calendar }; + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border", + defaultClassNames.dropdown_root + ), + dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-[--cell-size] select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-muted-foreground select-none text-[0.8rem]", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md", + defaultClassNames.day + ), + range_start: cn( + "bg-accent rounded-l-md", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +}) +CarouselNext.displayName = "CarouselNext" + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx new file mode 100644 index 0000000..39fba6d --- /dev/null +++ b/components/ui/chart.tsx @@ -0,0 +1,365 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +