first commit

This commit is contained in:
2026-07-08 11:08:24 +05:30
parent a5c745ba9f
commit 7dc984ebb9
44 changed files with 7870 additions and 192 deletions
+189
View File
@@ -0,0 +1,189 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
ChevronRight,
HelpCircle,
LayoutGrid,
Menu,
Package,
Settings,
ShoppingCart,
X,
type LucideIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
const navItems: { title: string; href: string; icon: LucideIcon; chevron?: boolean }[] = [
{ title: "Dashboard", href: "/dashboard", icon: LayoutGrid },
{ title: "Products", href: "/dashboard/products", icon: Package, chevron: true },
{ title: "Orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
{ title: "Settings", href: "/dashboard/settings", icon: Settings, chevron: true },
{ title: "Help", href: "/dashboard/help", icon: HelpCircle },
]
function SidebarContent({
collapsed,
onCollapse,
onClose,
pathname,
isMobile,
}: {
collapsed: boolean
onCollapse: () => void
onClose?: () => void
pathname: string
isMobile: boolean
}) {
return (
<nav
className={cn(
"flex h-full flex-col rounded-3xl bg-white p-3 shadow-sm ring-1 ring-black/5 transition-[width] duration-200",
!isMobile && (collapsed ? "w-20" : "w-64")
)}
>
{/* Header */}
<div
className={cn(
"mb-10 flex items-center gap-2.5 px-4 py-3",
!isMobile && collapsed ? "flex-col-reverse justify-center gap-3 px-0" : "justify-between"
)}
>
<Link href="/dashboard" className="flex items-center gap-2.5" onClick={onClose}>
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-indigo-50">
<svg viewBox="0 0 48 32" className="h-3.5 w-5 fill-indigo-600">
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
</svg>
</div>
{(!collapsed || isMobile) && (
<span className="text-lg font-bold tracking-tight text-slate-900">Hexa ERP</span>
)}
</Link>
<button
type="button"
onClick={isMobile ? onClose : onCollapse}
aria-label={isMobile ? "Close menu" : collapsed ? "Expand sidebar" : "Minimize sidebar"}
className="flex size-8 shrink-0 items-center justify-center rounded-xl text-slate-400 hover:bg-slate-50 hover:text-slate-600"
>
{isMobile ? <X className="size-4" /> : <Menu className="size-4" />}
</button>
</div>
{/* Nav items */}
<ul className="flex flex-col gap-1">
{navItems.map((item) => {
const isActive =
item.href === "/dashboard" ? pathname === item.href : pathname.startsWith(item.href)
return (
<li key={item.href}>
<Link
href={item.href}
title={!isMobile && collapsed ? item.title : undefined}
onClick={onClose}
className={cn(
"flex items-center gap-3 rounded-2xl px-4 py-3 text-base font-semibold transition-colors",
!isMobile && collapsed && "justify-center px-0",
isActive
? "bg-indigo-50 text-indigo-600"
: "text-slate-700 hover:bg-slate-50"
)}
>
<item.icon
className={cn("size-5 shrink-0", isActive ? "text-indigo-600" : "text-slate-400")}
/>
{(!collapsed || isMobile) && (
<>
<span className="flex-1">{item.title}</span>
{item.chevron && !isActive && (
<ChevronRight className="size-4 shrink-0 text-slate-300" />
)}
</>
)}
</Link>
</li>
)
})}
</ul>
<div className="mt-auto flex items-center justify-center pt-6">
<div className="flex size-12 items-center justify-center rounded-2xl bg-slate-50 ring-1 ring-black/5">
<svg viewBox="0 0 48 32" className="h-4 w-6 fill-slate-400">
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
</svg>
</div>
</div>
</nav>
)
}
export function AppSidebar() {
const pathname = usePathname()
const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
// Close mobile menu on route change
useEffect(() => {
const timeout = setTimeout(() => setMobileOpen(false), 0)
return () => clearTimeout(timeout)
}, [pathname])
// Prevent body scroll when mobile menu is open
useEffect(() => {
document.body.style.overflow = mobileOpen ? "hidden" : ""
return () => { document.body.style.overflow = "" }
}, [mobileOpen])
return (
<>
{/* ── Desktop sidebar ─────────────────────────────── */}
<div className="hidden lg:flex">
<SidebarContent
collapsed={collapsed}
onCollapse={() => setCollapsed((v) => !v)}
pathname={pathname}
isMobile={false}
/>
</div>
{/* ── Mobile hamburger trigger ─────────────────────── */}
<button
type="button"
onClick={() => setMobileOpen(true)}
aria-label="Open menu"
className="fixed top-5 left-5 z-40 flex size-10 items-center justify-center rounded-2xl bg-white shadow-sm ring-1 ring-black/5 text-slate-600 hover:bg-slate-50 lg:hidden"
>
<Menu className="size-5" />
</button>
{/* ── Mobile overlay backdrop ──────────────────────── */}
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-black/30 backdrop-blur-xs lg:hidden"
onClick={() => setMobileOpen(false)}
aria-hidden="true"
/>
)}
{/* ── Mobile drawer ────────────────────────────────── */}
<div
className={cn(
"fixed inset-y-0 left-0 z-50 w-72 p-3 transition-transform duration-200 lg:hidden",
mobileOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<SidebarContent
collapsed={false}
onCollapse={() => {}}
onClose={() => setMobileOpen(false)}
pathname={pathname}
isMobile={true}
/>
</div>
</>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { cn } from "@/lib/utils"
export function Footer({ className }: { className?: string }) {
const year = new Date().getFullYear()
return (
<footer
className={cn(
"w-full rounded-3xl bg-white shadow-sm ring-1 ring-black/5",
className
)}
>
<div className="flex flex-col items-center gap-3 p-5">
{/* Logo + name */}
<div className="flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-lg bg-indigo-600">
<span className="text-xs font-bold text-white">H</span>
</div>
<span className="text-sm font-bold text-slate-900">HexDive ERP</span>
</div>
{/* Copyright */}
<p className="text-xs text-slate-400">
© {year} HexDive ERP. All rights reserved.
</p>
</div>
</footer>
)
}
+203
View File
@@ -0,0 +1,203 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
function titleFromPath(pathname: string) {
const segment = pathname.split("/").filter(Boolean).pop() ?? "dashboard"
return segment.charAt(0).toUpperCase() + segment.slice(1)
}
interface Notification {
id: string
title: string
description: string
time: string
unread: boolean
}
const initialNotifications: Notification[] = [
{
id: "1",
title: "New order received",
description: "Order #3210 placed by John Martinez",
time: "2m ago",
unread: true,
},
{
id: "2",
title: "Low stock alert",
description: "SKU-2291 has dropped below its reorder threshold",
time: "1h ago",
unread: true,
},
{
id: "3",
title: "Payment failed",
description: "Order #3207 payment was declined",
time: "3h ago",
unread: false,
},
{
id: "4",
title: "New customer",
description: "Grace Kim just created an account",
time: "1d ago",
unread: false,
},
]
export function Header() {
const pathname = usePathname()
const router = useRouter()
const title = titleFromPath(pathname)
const showBackButton = pathname !== "/dashboard"
const [notifications, setNotifications] = useState(initialNotifications)
const unreadCount = notifications.filter((n) => n.unread).length
const markAllAsRead = () =>
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
return (
<header className="mb-6 flex items-center justify-between gap-2 rounded-3xl bg-white py-4 pr-4 pl-14 shadow-sm ring-1 ring-black/5 lg:gap-4 lg:p-4">
<div className="flex items-center gap-3">
{showBackButton && (
<button
type="button"
onClick={() => router.back()}
aria-label="Go back"
className="flex size-9 shrink-0 items-center justify-center rounded-full text-slate-500 hover:bg-slate-50 hover:text-slate-700"
>
<ArrowLeft className="size-5" />
</button>
)}
<h1 className="text-base font-bold tracking-tight text-slate-900 sm:text-xl">{title}</h1>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label="Notifications"
className="relative flex size-10 shrink-0 items-center justify-center rounded-full text-slate-500 hover:bg-slate-50 hover:text-slate-700"
/>
}
>
<Bell className="size-5" />
{unreadCount > 0 && (
<Badge className="absolute top-1.5 right-1.5 size-2 rounded-full bg-indigo-600 p-0" />
)}
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[calc(100vw-2rem)] max-w-sm sm:w-96"
>
<div className="flex items-center justify-between border-b border-black/5 px-4 py-3">
<p className="text-sm font-semibold text-slate-900">Notifications</p>
{unreadCount > 0 && (
<button
type="button"
onClick={markAllAsRead}
className="text-xs font-medium text-indigo-600 hover:underline"
>
Mark all as read
</button>
)}
</div>
<div className="max-h-80 overflow-y-auto">
{notifications.length === 0 ? (
<p className="p-6 text-center text-sm text-muted-foreground">
You&apos;re all caught up.
</p>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={cn(
"flex gap-3 border-b border-black/5 px-4 py-3 last:border-0",
notification.unread && "bg-indigo-50/50"
)}
>
<span
className={cn(
"mt-1.5 size-2 shrink-0 rounded-full",
notification.unread ? "bg-indigo-600" : "bg-transparent"
)}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-900">{notification.title}</p>
<p className="truncate text-sm text-muted-foreground">
{notification.description}
</p>
<p className="mt-1 text-xs text-slate-400">{notification.time}</p>
</div>
</div>
))
)}
</div>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-slate-50">
<Avatar>
<AvatarFallback className="bg-indigo-50 font-semibold text-indigo-600">
JM
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-semibold text-slate-700 sm:block">
John Martinez
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-2">
<div className="px-2 py-2.5">
<p className="text-base font-semibold text-slate-900">John Martinez</p>
<p className="text-sm font-normal text-muted-foreground">john52martinez@gmail.com</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
render={<Link href="/dashboard/profile" />}
className="gap-3 px-3 py-2.5 text-base [&_svg:not([class*='size-'])]:size-5"
>
<User />
Profile
</DropdownMenuItem>
<DropdownMenuItem
render={<Link href="/dashboard/settings" />}
className="gap-3 px-3 py-2.5 text-base [&_svg:not([class*='size-'])]:size-5"
>
<Settings />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
render={<Link href="/login" />}
className="gap-3 px-3 py-2.5 text-base [&_svg:not([class*='size-'])]:size-5"
>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)
}
@@ -0,0 +1,123 @@
"use client"
import { Eye, Pencil, Trash2 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { DataTable, type DataTableColumn } from "@/components/ui/data-table"
const orders = [
{ id: "#3210", customer: "John Martinez", date: "Jul 4, 2026", status: "Paid", items: 3, amount: 245.0 },
{ id: "#3209", customer: "Aisha Khan", date: "Jul 3, 2026", status: "Pending", items: 1, amount: 89.5 },
{ id: "#3208", customer: "Liam Chen", date: "Jul 3, 2026", status: "Paid", items: 5, amount: 512.2 },
{ id: "#3207", customer: "Sofia Rossi", date: "Jul 2, 2026", status: "Cancelled", items: 2, amount: 64.0 },
{ id: "#3206", customer: "Noah Williams", date: "Jul 1, 2026", status: "Paid", items: 4, amount: 178.9 },
{ id: "#3205", customer: "Emma Garcia", date: "Jun 30, 2026", status: "Paid", items: 2, amount: 132.4 },
{ id: "#3204", customer: "Yuki Tanaka", date: "Jun 30, 2026", status: "Pending", items: 6, amount: 340.75 },
{ id: "#3203", customer: "Priya Patel", date: "Jun 29, 2026", status: "Paid", items: 1, amount: 42.0 },
{ id: "#3202", customer: "Lucas Silva", date: "Jun 28, 2026", status: "Cancelled", items: 3, amount: 97.6 },
{ id: "#3201", customer: "Olivia Brown", date: "Jun 27, 2026", status: "Paid", items: 4, amount: 210.3 },
{ id: "#3200", customer: "Mateo Alvarez", date: "Jun 26, 2026", status: "Paid", items: 2, amount: 88.0 },
{ id: "#3199", customer: "Grace Kim", date: "Jun 25, 2026", status: "Pending", items: 5, amount: 265.5 },
] as const
type Order = (typeof orders)[number]
const statusStyles: Record<Order["status"], string> = {
Paid: "bg-emerald-50 text-emerald-700",
Pending: "bg-amber-50 text-amber-700",
Cancelled: "bg-rose-50 text-rose-700",
}
const currency = new Intl.NumberFormat(undefined, {
style: "currency",
currency: "USD",
})
const columns: DataTableColumn<Order>[] = [
{
key: "id",
header: "Order",
cell: (order) => <span className="font-medium text-slate-900">{order.id}</span>,
},
{
key: "customer",
header: "Customer",
cell: (order) => order.customer,
},
{
key: "date",
header: "Date",
cell: (order) => order.date,
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden text-muted-foreground sm:table-cell",
},
{
key: "items",
header: "Items",
cell: (order) => order.items,
headerClassName: "hidden md:table-cell",
cellClassName: "hidden text-muted-foreground md:table-cell",
},
{
key: "status",
header: "Status",
cell: (order) => <Badge className={statusStyles[order.status]}>{order.status}</Badge>,
},
{
key: "amount",
header: "Amount",
cell: (order) => currency.format(order.amount),
headerClassName: "text-right",
cellClassName: "text-right font-medium text-slate-900",
},
]
export function RecentOrdersTable() {
return (
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-black/5">
<h2 className="mb-4 text-lg font-bold tracking-tight text-slate-900">Recent Orders</h2>
<DataTable
data={orders}
columns={columns}
getRowId={(order) => order.id}
searchAccessor={(order) => `${order.id} ${order.customer}`}
searchPlaceholder="Search orders..."
filters={[
{
key: "status",
label: "Status",
options: ["Paid", "Pending", "Cancelled"],
accessor: (order) => order.status,
},
]}
pageSize={5}
actions={(order) => (
<div className="flex items-center justify-end gap-1">
<button
type="button"
aria-label={`View ${order.id}`}
className="flex size-8 items-center justify-center rounded-full text-slate-500 hover:bg-slate-50 hover:text-slate-700"
>
<Eye className="size-4" />
</button>
<button
type="button"
aria-label={`Edit ${order.id}`}
className="flex size-8 items-center justify-center rounded-full text-slate-500 hover:bg-slate-50 hover:text-slate-700"
>
<Pencil className="size-4" />
</button>
<button
type="button"
aria-label={`Delete ${order.id}`}
className="flex size-8 items-center justify-center rounded-full text-rose-500 hover:bg-rose-50"
>
<Trash2 className="size-4" />
</button>
</div>
)}
/>
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { AlertTriangleIcon, InfoIcon, XCircleIcon, CheckCircle2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/20 backdrop-blur-xs duration-150 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
type AlertVariant = "info" | "warning" | "destructive" | "success"
const variantConfig: Record<
AlertVariant,
{ icon: React.ElementType; iconClass: string; ringClass: string }
> = {
info: {
icon: InfoIcon,
iconClass: "text-sky-600 bg-sky-50",
ringClass: "ring-sky-100",
},
warning: {
icon: AlertTriangleIcon,
iconClass: "text-amber-600 bg-amber-50",
ringClass: "ring-amber-100",
},
destructive: {
icon: XCircleIcon,
iconClass: "text-red-600 bg-red-50",
ringClass: "ring-red-100",
},
success: {
icon: CheckCircle2Icon,
iconClass: "text-emerald-600 bg-emerald-50",
ringClass: "ring-emerald-100",
},
}
interface AlertDialogContentProps extends AlertDialogPrimitive.Popup.Props {
variant?: AlertVariant
title: string
description?: string
confirmLabel?: string
cancelLabel?: string
onConfirm?: () => void
onCancel?: () => void
}
function AlertDialogContent({
className,
variant = "info",
title,
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
onConfirm,
onCancel,
...props
}: AlertDialogContentProps) {
const { icon: Icon, iconClass, ringClass } = variantConfig[variant]
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-white p-6 shadow-xl ring-1 duration-150 outline-none sm:max-w-sm",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
ringClass,
className
)}
{...props}
>
<div className="flex flex-col items-center gap-4 text-center">
<div className={cn("flex size-14 items-center justify-center rounded-full", iconClass)}>
<Icon className="size-7" />
</div>
<div className="flex flex-col gap-1.5">
<AlertDialogPrimitive.Title className="text-base font-bold text-slate-900">
{title}
</AlertDialogPrimitive.Title>
{description && (
<AlertDialogPrimitive.Description className="text-sm text-slate-500">
{description}
</AlertDialogPrimitive.Description>
)}
</div>
<div className="mt-1 flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-center">
<AlertDialogPrimitive.Close
render={
<Button variant="outline" className="sm:min-w-24" onClick={onCancel} />
}
>
{cancelLabel}
</AlertDialogPrimitive.Close>
<AlertDialogPrimitive.Close
render={
<Button
variant={variant === "destructive" ? "destructive" : variant === "success" ? "success" : variant === "warning" ? "warning" : "info"}
className="sm:min-w-24"
onClick={onConfirm}
/>
}
>
{confirmLabel}
</AlertDialogPrimitive.Close>
</div>
</div>
</AlertDialogPrimitive.Popup>
</AlertDialogPortal>
)
}
export {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogPortal,
AlertDialogOverlay,
}
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+40
View File
@@ -0,0 +1,40 @@
"use client"
import React from "react"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from "chart.js"
import { Bar } from "react-chartjs-2"
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
type Props = {
labels: string[]
datasets: Array<{
label?: string
data: number[]
backgroundColor?: string | string[]
}>
}
export default function BarChart({ labels, datasets }: Props) {
const data = { labels, datasets }
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: "top" as const } },
scales: { y: { beginAtZero: true } },
}
return (
<div style={{ width: "100%", minHeight: 260 }}>
<Bar data={data} options={options} />
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground sm:gap-2",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
...props
}: React.ComponentProps<"a">) {
return (
<a
data-slot="breadcrumb-link"
className={cn(
"truncate transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm",
className
)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-current="page"
aria-disabled="true"
className={cn("truncate font-medium text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("text-muted-foreground/60 [&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
)
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-6 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+63
View File
@@ -0,0 +1,63 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
success:
"bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20 focus-visible:border-emerald-400/40 focus-visible:ring-emerald-400/20 dark:bg-emerald-500/20 dark:text-emerald-400 dark:hover:bg-emerald-500/30 dark:focus-visible:ring-emerald-400/40",
warning:
"bg-amber-500/10 text-amber-600 hover:bg-amber-500/20 focus-visible:border-amber-400/40 focus-visible:ring-amber-400/20 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30 dark:focus-visible:ring-amber-400/40",
info:
"bg-sky-500/10 text-sky-600 hover:bg-sky-500/20 focus-visible:border-sky-400/40 focus-visible:ring-sky-400/20 dark:bg-sky-500/20 dark:text-sky-400 dark:hover:bg-sky-500/30 dark:focus-visible:ring-sky-400/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>
export type ButtonSize = NonNullable<VariantProps<typeof buttonVariants>["size"]>
export type ButtonProps = ButtonPrimitive.Props & VariantProps<typeof buttonVariants>
function Button({ className, variant = "default", size = "default", ...props }: ButtonProps) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+72
View File
@@ -0,0 +1,72 @@
"use client"
import * as React from "react"
import { DayPicker } from "react-day-picker"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-4",
month: "flex flex-col gap-4",
month_caption: "flex justify-center relative items-center",
caption_label: "text-sm font-medium",
nav: "flex items-center gap-1",
button_previous: cn(
buttonVariants({ variant: "outline", size: "icon-sm" }),
"absolute left-1 top-1 h-7 w-7"
),
button_next: cn(
buttonVariants({ variant: "outline", size: "icon-sm" }),
"absolute right-1 top-1 h-7 w-7"
),
month_grid: "w-full border-collapse",
weekdays: "flex",
weekday:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem] flex items-center justify-center",
week: "flex w-full mt-2",
day: "relative p-0 text-center text-sm",
day_button: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 rounded-md p-0 font-normal aria-selected:opacity-100"
),
selected:
"[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground [&>button]:focus:bg-primary [&>button]:focus:text-primary-foreground",
today: "[&>button]:bg-accent [&>button]:text-accent-foreground",
outside:
"day-outside [&>button]:text-muted-foreground [&>button]:opacity-50",
disabled: "[&>button]:text-muted-foreground [&>button]:opacity-50",
range_middle:
"[&>button]:bg-accent [&>button]:text-accent-foreground [&>button]:hover:bg-accent [&>button]:hover:text-accent-foreground",
range_start: "day-range-start",
range_end: "day-range-end",
hidden: "invisible",
...classNames,
}}
components={{
Chevron: ({ orientation }) =>
orientation === "left" ? (
<ChevronLeftIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
),
}}
{...props}
/>
)
}
export { Calendar }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+29
View File
@@ -0,0 +1,29 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+248
View File
@@ -0,0 +1,248 @@
"use client"
import { type ReactNode, useMemo, useState } from "react"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
export interface DataTableColumn<T> {
key: string
header: string
cell: (row: T) => ReactNode
headerClassName?: string
cellClassName?: string
}
export interface DataTableFilter<T> {
key: string
label: string
options: string[]
accessor: (row: T) => string
}
export interface DataTableProps<T> {
data: readonly T[]
columns: DataTableColumn<T>[]
getRowId: (row: T) => string | number
searchAccessor?: (row: T) => string
searchPlaceholder?: string
filters?: DataTableFilter<T>[]
actions?: (row: T) => ReactNode
pageSize?: number
emptyMessage?: string
}
const ALL_VALUE = "All"
export function DataTable<T>({
data,
columns,
getRowId,
searchAccessor,
searchPlaceholder = "Search...",
filters = [],
actions,
pageSize = 5,
emptyMessage = "No results found.",
}: DataTableProps<T>) {
const [query, setQuery] = useState("")
const [activeFilters, setActiveFilters] = useState<Record<string, string>>({})
const [page, setPage] = useState(1)
const filteredData = useMemo(() => {
let rows = data
if (query.trim() && searchAccessor) {
const q = query.trim().toLowerCase()
rows = rows.filter((row) => searchAccessor(row).toLowerCase().includes(q))
}
for (const filter of filters) {
const active = activeFilters[filter.key]
if (active && active !== ALL_VALUE) {
rows = rows.filter((row) => filter.accessor(row) === active)
}
}
return rows
}, [data, query, searchAccessor, filters, activeFilters])
const totalPages = Math.max(1, Math.ceil(filteredData.length / pageSize))
const currentPage = Math.min(page, totalPages)
const start = (currentPage - 1) * pageSize
const visibleRows = filteredData.slice(start, start + pageSize)
const goTo = (target: number) => setPage(Math.min(Math.max(target, 1), totalPages))
const setFilter = (key: string, value: string) => {
setActiveFilters((prev) => ({ ...prev, [key]: value }))
setPage(1)
}
return (
<div className="flex flex-col gap-4">
{(searchAccessor || filters.length > 0) && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{searchAccessor && (
<div className="relative w-full sm:max-w-xs">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-slate-400" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value)
setPage(1)
}}
placeholder={searchPlaceholder}
className="h-10 rounded-full bg-slate-50 pl-9"
/>
</div>
)}
{filters.map((filter) => {
const active = activeFilters[filter.key] ?? ALL_VALUE
return (
<div
key={filter.key}
className="flex flex-1 flex-wrap items-center gap-2 sm:justify-end"
>
{[ALL_VALUE, ...filter.options].map((option) => (
<Button
key={option}
type="button"
size="sm"
variant={active === option ? "default" : "outline"}
className="rounded-full px-4"
onClick={() => setFilter(filter.key, option)}
>
{option}
</Button>
))}
</div>
)
})}
</div>
)}
<Table>
<TableHeader>
<TableRow className="bg-indigo-50 hover:bg-indigo-50">
{columns.map((column, index) => (
<TableHead
key={column.key}
className={cn(
"text-indigo-700",
index === 0 && "rounded-l-lg pl-3",
index === columns.length - 1 && !actions && "rounded-r-lg pr-3",
column.headerClassName
)}
>
{column.header}
</TableHead>
))}
{actions && (
<TableHead className="rounded-r-lg pr-3 text-right text-indigo-700">
Actions
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.length === 0 && (
<TableRow>
<TableCell
colSpan={columns.length + (actions ? 1 : 0)}
className="py-8 text-center text-muted-foreground"
>
{emptyMessage}
</TableCell>
</TableRow>
)}
{visibleRows.map((row) => (
<TableRow key={getRowId(row)}>
{columns.map((column, index) => (
<TableCell
key={column.key}
className={cn(index === 0 && "pl-3", column.cellClassName)}
>
{column.cell(row)}
</TableCell>
))}
{actions && (
<TableCell className="pr-3 text-right">{actions(row)}</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
{totalPages > 1 && (
<Pagination className="justify-between">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault()
goTo(currentPage - 1)
}}
aria-disabled={currentPage === 1}
className={currentPage === 1 ? "pointer-events-none opacity-50" : undefined}
/>
</PaginationItem>
</PaginationContent>
<PaginationContent>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNumber) => (
<PaginationItem key={pageNumber}>
<PaginationLink
href="#"
isActive={pageNumber === currentPage}
onClick={(e) => {
e.preventDefault()
goTo(pageNumber)
}}
>
{pageNumber}
</PaginationLink>
</PaginationItem>
))}
</PaginationContent>
<PaginationContent>
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault()
goTo(currentPage + 1)
}}
aria-disabled={currentPage === totalPages}
className={
currentPage === totalPages ? "pointer-events-none opacity-50" : undefined
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
)
}
+131
View File
@@ -0,0 +1,131 @@
"use client"
import * as React from "react"
import { format } from "date-fns"
import { CalendarIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface DatePickerProps {
value?: Date
onChange?: (date: Date | undefined) => void
placeholder?: string
disabled?: boolean
className?: string
fromDate?: Date
toDate?: Date
}
function DatePicker({
value,
onChange,
placeholder = "Pick a date",
disabled = false,
className,
fromDate,
toDate,
}: DatePickerProps) {
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
disabled={disabled}
className={cn(
"h-10 w-full justify-start text-left font-normal sm:w-60",
!value && "text-muted-foreground",
className
)}
/>
}
>
<CalendarIcon className="mr-2 size-4 shrink-0" />
<span className="truncate">
{value ? format(value, "PPP") : placeholder}
</span>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={value}
onSelect={onChange}
disabled={(date) => {
if (fromDate && date < fromDate) return true
if (toDate && date > toDate) return true
return false
}}
autoFocus
/>
</PopoverContent>
</Popover>
)
}
interface DateRangePickerProps {
value?: { from: Date | undefined; to?: Date | undefined }
onChange?: (range: { from: Date | undefined; to?: Date | undefined } | undefined) => void
placeholder?: string
disabled?: boolean
className?: string
}
function DateRangePicker({
value,
onChange,
placeholder = "Pick a date range",
disabled = false,
className,
}: DateRangePickerProps) {
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
disabled={disabled}
className={cn(
"h-10 w-full justify-start text-left font-normal sm:w-75",
!value?.from && "text-muted-foreground",
className
)}
/>
}
>
<CalendarIcon className="mr-2 size-4 shrink-0" />
<span className="truncate">
{value?.from ? (
value.to ? (
<>
{format(value.from, "LLL dd, y")} {" "}
{format(value.to, "LLL dd, y")}
</>
) : (
format(value.from, "LLL dd, y")
)
) : (
placeholder
)}
</span>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
selected={value}
onSelect={onChange as (range: unknown) => void}
numberOfMonths={2}
autoFocus
/>
</PopoverContent>
</Popover>
)
}
export { DatePicker, DateRangePicker }
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+268
View File
@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+238
View File
@@ -0,0 +1,238 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+20
View File
@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<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
)}
{...props}
/>
)
}
export { Label }
+70
View File
@@ -0,0 +1,70 @@
"use client"
import React from "react"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from "chart.js"
import { Line } from "react-chartjs-2"
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
type Props = {
labels: string[]
datasets: Array<{
label?: string
data: number[]
borderColor?: string
backgroundColor?: string
fill?: boolean
}>
}
export default function LineChart({ labels, datasets }: Props) {
const data = {
labels,
datasets: datasets.map((d) => ({
label: d.label,
data: d.data,
borderColor: d.borderColor || "rgba(75,192,192,1)",
backgroundColor: d.backgroundColor || "rgba(75,192,192,0.2)",
fill: d.fill ?? true,
tension: 0.3,
pointRadius: 3,
})),
}
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: "top" as const },
title: { display: false },
},
scales: {
x: { grid: { display: false } },
y: { beginAtZero: true },
},
}
return (
<div style={{ width: "100%", minHeight: 240 }}>
<Line data={data} options={options} />
</div>
)
}
+130
View File
@@ -0,0 +1,130 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<Button
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
nativeButton={false}
render={
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
{...props}
/>
}
/>
)
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
)
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}
+37
View File
@@ -0,0 +1,37 @@
"use client"
import React from "react"
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js"
import { Pie } from "react-chartjs-2"
ChartJS.register(ArcElement, Tooltip, Legend)
type Props = {
labels: string[]
data: number[]
backgroundColor?: string[]
}
export default function PieChart({ labels, data: values, backgroundColor }: Props) {
const data = {
labels,
datasets: [
{
data: values,
backgroundColor: backgroundColor || ["#4ade80", "#60a5fa", "#f97316", "#f43f5e"],
hoverOffset: 6,
},
],
}
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: "right" as const } },
}
return (
<div style={{ width: "100%", minHeight: 220 }}>
<Pie data={data} options={options} />
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverPortal({ ...props }: PopoverPrimitive.Portal.Props) {
return <PopoverPrimitive.Portal data-slot="popover-portal" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 6,
...props
}: PopoverPrimitive.Popup.Props & {
align?: "start" | "center" | "end"
sideOffset?: number
}) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
sideOffset={sideOffset}
align={align}
className="z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"w-auto rounded-xl bg-popover p-0 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverClose({ ...props }: PopoverPrimitive.Close.Props) {
return <PopoverPrimitive.Close data-slot="popover-close" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverClose, PopoverPortal }
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+138
View File
@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+723
View File
@@ -0,0 +1,723 @@
"use client"
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+108
View File
@@ -0,0 +1,108 @@
import { type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function formatValue(value: string | number) {
if (typeof value !== "number") return value
const formatted = Intl.NumberFormat(undefined, {
notation: "compact",
maximumFractionDigits: 1,
}).format(value)
// Normalize compact suffix casing to a consistent form to avoid
// hydration mismatches between server and client environments.
return formatted.replace(/(k|K|m|M|b|B)$/u, (s) => s.toUpperCase())
}
function Sparkline({ points }: { points: number[] }) {
const width = 48
const height = 18
const min = Math.min(...points)
const max = Math.max(...points)
const range = max - min || 1
const coords = points.map((point, index) => {
const x = (index / (points.length - 1)) * width
const y = height - ((point - min) / range) * height
return [x, y] as const
})
const path = coords.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x},${y}`).join(" ")
const [lastX, lastY] = coords[coords.length - 1]
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-4.5 w-12 shrink-0 overflow-visible"
aria-hidden="true"
>
<path d={path} fill="none" stroke="#cbd5e1" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<circle cx={lastX} cy={lastY} r={2.5} className="fill-indigo-600" stroke="white" strokeWidth={1.5} />
</svg>
)
}
export interface StatCardProps {
label: string
value: string | number
icon?: LucideIcon
delta?: number
deltaLabel?: string
/** Set true for metrics where an increase is bad (e.g. churn, costs). */
invert?: boolean
trend?: number[]
className?: string
}
export function StatCard({
label,
value,
icon: Icon,
delta,
deltaLabel,
invert = false,
trend,
className,
}: StatCardProps) {
const hasDelta = typeof delta === "number" && delta !== 0
const isPositive = hasDelta && delta > 0
const isGood = hasDelta && (invert ? !isPositive : isPositive)
return (
<div
className={cn(
"flex flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-black/5 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-indigo-200",
className
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-base font-medium text-muted-foreground">{label}</p>
{Icon && (
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50">
<Icon className="size-5 text-indigo-600" />
</div>
)}
</div>
<div className="flex items-end justify-between gap-2">
<p className="text-xl font-bold tracking-tight text-slate-900">{formatValue(value)}</p>
{trend && trend.length > 1 && <Sparkline points={trend} />}
</div>
{hasDelta && (
<div className="flex items-center gap-1 text-sm">
<span
className={cn(
"font-semibold",
isGood ? "text-[#0ca30c]" : "text-[#d03b3b]"
)}
>
{isPositive ? "+" : "-"}
{Math.abs(delta).toFixed(1)}%
</span>
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
</div>
)}
</div>
)
}
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+115
View File
@@ -0,0 +1,115 @@
"use client"
import * as React from "react"
import { Toast as ToastPrimitive } from "@base-ui/react/toast"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
export const toastManager = ToastPrimitive.createToastManager()
type ToastType = "default" | "success" | "error" | "warning" | "info"
interface ToastData {
type?: ToastType
}
function ToastList() {
const { toasts } = ToastPrimitive.useToastManager<ToastData>()
return (
<ToastPrimitive.Viewport className="fixed top-4 right-4 z-9999 flex w-full max-w-sm flex-col gap-2 outline-none sm:top-6 sm:right-6">
{toasts.map((toast) => (
<ToastPrimitive.Root
key={toast.id}
toast={toast}
className={cn(
"group relative flex w-full flex-col gap-1 overflow-hidden rounded-xl px-4 py-3 text-sm shadow-lg ring-1 transition-all duration-200",
"data-[transitionstatus=starting]:-translate-y-2 data-[transitionstatus=starting]:opacity-0",
"data-[transitionstatus=ending]:translate-x-full data-[transitionstatus=ending]:opacity-0",
toast.data?.type === "success" &&
"bg-emerald-50 text-emerald-900 ring-emerald-200 dark:bg-emerald-950 dark:text-emerald-100 dark:ring-emerald-800",
toast.data?.type === "error" &&
"bg-red-50 text-red-900 ring-red-200 dark:bg-red-950 dark:text-red-100 dark:ring-red-800",
toast.data?.type === "warning" &&
"bg-amber-50 text-amber-900 ring-amber-200 dark:bg-amber-950 dark:text-amber-100 dark:ring-amber-800",
toast.data?.type === "info" &&
"bg-sky-50 text-sky-900 ring-sky-200 dark:bg-sky-950 dark:text-sky-100 dark:ring-sky-800",
(!toast.data?.type || toast.data.type === "default") &&
"bg-white text-foreground ring-foreground/10 dark:bg-neutral-900"
)}
>
<ToastPrimitive.Content className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-0.5">
{toast.title && (
<ToastPrimitive.Title className="font-semibold leading-snug">
{toast.title}
</ToastPrimitive.Title>
)}
{toast.description && (
<ToastPrimitive.Description className="text-xs opacity-80">
{toast.description}
</ToastPrimitive.Description>
)}
</div>
<ToastPrimitive.Close className="mt-0.5 shrink-0 rounded-md p-1 opacity-60 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<XIcon className="size-3.5" />
<span className="sr-only">Dismiss</span>
</ToastPrimitive.Close>
</ToastPrimitive.Content>
{toast.actionProps && (
<ToastPrimitive.Action
{...toast.actionProps}
className="mt-1 self-start rounded-md px-2 py-1 text-xs font-medium underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
)}
</ToastPrimitive.Root>
))}
</ToastPrimitive.Viewport>
)
}
function Toaster() {
return (
<ToastPrimitive.Provider toastManager={toastManager}>
<ToastList />
</ToastPrimitive.Provider>
)
}
function toast(options: {
title?: string
description?: string
type?: ToastType
timeout?: number
actionLabel?: string
onAction?: () => void
}) {
toastManager.add({
title: options.title,
description: options.description,
timeout: options.timeout ?? 4000,
data: { type: options.type ?? "default" },
...(options.actionLabel && {
actionProps: {
children: options.actionLabel,
onClick: options.onAction,
},
}),
})
}
toast.success = (title: string, description?: string) =>
toast({ title, description, type: "success" })
toast.error = (title: string, description?: string) =>
toast({ title, description, type: "error" })
toast.warning = (title: string, description?: string) =>
toast({ title, description, type: "warning" })
toast.info = (title: string, description?: string) =>
toast({ title, description, type: "info" })
export { Toaster, toast }
+66
View File
@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }