🎉 feat: initialization

This commit is contained in:
web
2025-11-26 19:56:16 -08:00
commit a801849fb2
553 changed files with 213088 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# `@workspace/typescript-config`
Shared typescript configuration for the workspace.
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Default",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"incremental": false,
"isolatedModules": true,
"lib": ["es2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleDetection": "force",
"moduleResolution": "NodeNext",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Next.js",
"extends": "./base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"jsx": "preserve",
"noEmit": true
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@workspace/typescript-config",
"version": "0.0.0",
"private": true,
"license": "PROPRIETARY",
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "React Library",
"extends": "./base.json",
"compilerOptions": {
"jsx": "react-jsx"
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@workspace/ui/components",
"utils": "@workspace/ui/lib/utils",
"hooks": "@workspace/ui/hooks",
"lib": "@workspace/ui/lib",
"ui": "@workspace/ui/components"
}
}
+25
View File
@@ -0,0 +1,25 @@
const config = [
{
requestLibPath: "import request from '@workspace/ui/lib/request';",
schemaPath:
"https://raw.githubusercontent.com/perfect-panel/ppanel-docs/refs/heads/main/public/swagger/common.json",
serversPath: "./src/services",
projectName: "common",
},
{
requestLibPath: "import request from '@workspace/ui/lib/request';",
schemaPath:
"https://raw.githubusercontent.com/perfect-panel/ppanel-docs/refs/heads/main/public/swagger/user.json",
serversPath: "./src/services",
projectName: "user",
},
{
requestLibPath: "import request from '@workspace/ui/lib/request';",
schemaPath:
"https://raw.githubusercontent.com/perfect-panel/ppanel-docs/refs/heads/main/public/swagger/admin.json",
serversPath: "./src/services",
projectName: "admin",
},
];
export default config;
+108
View File
@@ -0,0 +1,108 @@
{
"name": "@workspace/ui",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"lint": "biome lint",
"check": "biome check",
"openapi": "openapi2ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@iconify/react": "^6.0.2",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-direction": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-router": "^1.139.0",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"input-otp": "^1.4.2",
"lucide-react": "^0.554.0",
"mathjs": "^15.1.0",
"monaco-editor": "^0.55.1",
"monaco-themes": "^0.4.7",
"motion": "^12.23.24",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.0",
"react-hook-form": "^7.66.1",
"react-i18next": "^16.3.5",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"react-syntax-highlighter": "^16.1.0",
"react-top-loading-bar": "^3.0.2",
"recharts": "2.15.4",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"remark-toc": "^9.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.3.6",
"vaul": "^1.1.2",
"zod": "^4.1.12"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
"@tanstack/react-query-devtools": "^5.90.2",
"@turbo/gen": "^2.5.5",
"@types/node": "^20.19.9",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@types/react-syntax-highlighter": "^15.5.13",
"@umijs/openapi": "^1.14.1",
"@workspace/typescript-config": "workspace:*",
"tailwindcss": "^4.1.11",
"typescript": "^5.9.2"
},
"exports": {
"./globals.css": "./src/styles/globals.css",
"./postcss.config": "./postcss.config.mjs",
"./lib/*": "./src/lib/*.ts",
"./utils/*": "./src/utils/*.ts",
"./components/*": "./src/components/*.tsx",
"./composed/*": "./src/composed/*.tsx",
"./hooks/*": "./src/hooks/*.ts",
"./integrations/*": "./src/integrations/*.tsx",
"./services/*": "./src/services/*.ts"
}
}
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: { "@tailwindcss/postcss": {} },
};
export default config;
View File
+65
View File
@@ -0,0 +1,65 @@
"use client";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { cn } from "@workspace/ui/lib/utils";
import { ChevronDownIcon } from "lucide-react";
import type * as React from "react";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
className={cn("border-b last:border-b-0", className)}
data-slot="accordion-item"
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
className={cn(
"flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left font-medium text-sm outline-none transition-all hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
data-slot="accordion-trigger"
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
data-slot="accordion-content"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+157
View File
@@ -0,0 +1,157 @@
"use client";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { buttonVariants } from "@workspace/ui/components/button";
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
data-slot="alert-dialog-overlay"
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg",
className
)}
data-slot="alert-dialog-content"
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
data-slot="alert-dialog-header"
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
data-slot="alert-dialog-footer"
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
className={cn("font-semibold text-lg", className)}
data-slot="alert-dialog-title"
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
className={cn("text-muted-foreground text-sm", className)}
data-slot="alert-dialog-description"
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+65
View File
@@ -0,0 +1,65 @@
import { cn } from "@workspace/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
className={cn(alertVariants({ variant }), className)}
data-slot="alert"
role="alert"
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
data-slot="alert-title"
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
"col-start-2 grid justify-items-start gap-1 text-muted-foreground text-sm [&_p]:leading-relaxed",
className
)}
data-slot="alert-description"
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };
@@ -0,0 +1,11 @@
"use client";
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio };
+52
View File
@@ -0,0 +1,52 @@
"use client";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
data-slot="avatar"
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
className={cn("aspect-square size-full", className)}
data-slot="avatar-image"
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted",
className
)}
data-slot="avatar-fallback"
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };
+45
View File
@@ -0,0 +1,45 @@
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@workspace/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 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:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
className={cn(badgeVariants({ variant }), className)}
data-slot="badge"
{...props}
/>
);
}
export { Badge, badgeVariants };
+108
View File
@@ -0,0 +1,108 @@
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@workspace/ui/lib/utils";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import type * as React from "react";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
className={cn(
"wrap-break-word flex flex-wrap items-center gap-1.5 text-muted-foreground text-sm sm:gap-2.5",
className
)}
data-slot="breadcrumb-list"
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
className={cn("inline-flex items-center gap-1.5", className)}
data-slot="breadcrumb-item"
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
className={cn("transition-colors hover:text-foreground", className)}
data-slot="breadcrumb-link"
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
aria-current="page"
aria-disabled="true"
className={cn("font-normal text-foreground", className)}
data-slot="breadcrumb-page"
role="link"
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
data-slot="breadcrumb-separator"
role="presentation"
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
data-slot="breadcrumb-ellipsis"
role="presentation"
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
@@ -0,0 +1,83 @@
import { Slot } from "@radix-ui/react-slot";
import { Separator } from "@workspace/ui/components/separator";
import { cn } from "@workspace/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
className={cn(buttonGroupVariants({ orientation }), className)}
data-orientation={orientation}
data-slot="button-group"
role="group"
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "div";
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-md border bg-muted px-4 font-medium text-sm shadow-xs [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
className={cn(
"!m-0 relative self-stretch bg-input data-[orientation=vertical]:h-auto",
className
)}
data-slot="button-group-separator"
orientation={orientation}
{...props}
/>
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};
+59
View File
@@ -0,0 +1,59 @@
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@workspace/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
data-slot="button"
{...props}
/>
);
}
export { Button, buttonVariants };
+215
View File
@@ -0,0 +1,215 @@
"use client";
import { Button, buttonVariants } from "@workspace/ui/components/button";
import { cn } from "@workspace/ui/lib/utils";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import * as React from "react";
import {
type DayButton,
DayPicker,
getDefaultClassNames,
} from "react-day-picker";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
captionLayout={captionLayout}
className={cn(
"group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 select-none rounded-md font-normal text-[0.8rem] text-muted-foreground",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"select-none text-[0.8rem] text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => (
<div
className={cn(className)}
data-slot="calendar"
ref={rootRef}
{...props}
/>
),
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
);
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
),
...components,
}}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
showOutsideDays={showOutsideDays}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
className={cn(
"flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-start=true]:rounded-l-md data-[range-end=true]:bg-primary data-[range-middle=true]:bg-accent data-[range-start=true]:bg-primary data-[selected-single=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:text-accent-foreground data-[range-start=true]:text-primary-foreground data-[selected-single=true]:text-primary-foreground group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
data-day={day.date.toLocaleDateString()}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
data-range-start={modifiers.range_start}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
ref={ref}
size="icon"
variant="ghost"
{...props}
/>
);
}
export { Calendar, CalendarDayButton };
+91
View File
@@ -0,0 +1,91 @@
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
data-slot="card"
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
data-slot="card-header"
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("font-semibold leading-none", className)}
data-slot="card-title"
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="card-description"
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
data-slot="card-action"
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("px-6", className)}
data-slot="card-content"
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
data-slot="card-footer"
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+240
View File
@@ -0,0 +1,240 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import { cn } from "@workspace/ui/lib/utils";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import * as React from "react";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!(api && setApi)) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
aria-roledescription="carousel"
className={cn("relative", className)}
data-slot="carousel"
onKeyDownCapture={handleKeyDown}
role="region"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
className="overflow-hidden"
data-slot="carousel-content"
ref={carouselRef}
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
data-slot="carousel-item"
role="group"
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "-left-12 -translate-y-1/2 top-1/2"
: "-top-12 -translate-x-1/2 left-1/2 rotate-90",
className
)}
data-slot="carousel-previous"
disabled={!canScrollPrev}
onClick={scrollPrev}
size={size}
variant={variant}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "-right-12 -translate-y-1/2 top-1/2"
: "-bottom-12 -translate-x-1/2 left-1/2 rotate-90",
className
)}
data-slot="carousel-next"
disabled={!canScrollNext}
onClick={scrollNext}
size={size}
variant={variant}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};
+356
View File
@@ -0,0 +1,356 @@
"use client";
import { cn } from "@workspace/ui/lib/utils";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden",
className
)}
data-chart={chartId}
data-slot="chart"
{...props}
>
<ChartStyle config={config} id={chartId} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!(active && payload?.length)) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{nestLabel ? null : tooltipLabel}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
key={item.dataKey}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-medium font-mono text-foreground tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
key={item.value}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
+31
View File
@@ -0,0 +1,31 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { cn } from "@workspace/ui/lib/utils";
import { CheckIcon } from "lucide-react";
import type * as React from "react";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:data-[state=checked]:bg-primary dark:aria-invalid:ring-destructive/40",
className
)}
data-slot="checkbox"
{...props}
>
<CheckboxPrimitive.Indicator
className="grid place-content-center text-current transition-none"
data-slot="checkbox-indicator"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
@@ -0,0 +1,33 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+183
View File
@@ -0,0 +1,183 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import { cn } from "@workspace/ui/lib/utils";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import type * as React from "react";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
data-slot="command"
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
className="flex h-9 items-center gap-2 border-b px-3"
data-slot="command-input-wrapper"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
data-slot="command-input"
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
className={cn(
"max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden",
className
)}
data-slot="command-list"
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
className="py-6 text-center text-sm"
data-slot="command-empty"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:text-xs",
className
)}
data-slot="command-group"
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
className={cn("-mx-1 h-px bg-border", className)}
data-slot="command-separator"
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-slot="command-item"
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
className={cn(
"ml-auto text-muted-foreground text-xs tracking-widest",
className
)}
data-slot="command-shortcut"
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+251
View File
@@ -0,0 +1,251 @@
"use client";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { cn } from "@workspace/ui/lib/utils";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-inset={inset}
data-slot="context-menu-sub-trigger"
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
data-slot="context-menu-sub-content"
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
data-slot="context-menu-content"
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
className={cn(
"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-inset={inset}
data-slot="context-menu-item"
data-variant={variant}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-slot="context-menu-checkbox-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-slot="context-menu-radio-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
className={cn(
"px-2 py-1.5 font-medium text-foreground text-sm data-[inset]:pl-8",
className
)}
data-inset={inset}
data-slot="context-menu-label"
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
className={cn("-mx-1 my-1 h-px bg-border", className)}
data-slot="context-menu-separator"
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
className={cn(
"ml-auto text-muted-foreground text-xs tracking-widest",
className
)}
data-slot="context-menu-shortcut"
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
+142
View File
@@ -0,0 +1,142 @@
"use client";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@workspace/ui/lib/utils";
import { XIcon } from "lucide-react";
import type * as React from "react";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
data-slot="dialog-overlay"
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg",
className
)}
data-slot="dialog-content"
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
data-slot="dialog-close"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
data-slot="dialog-header"
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
data-slot="dialog-footer"
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn("font-semibold text-lg leading-none", className)}
data-slot="dialog-title"
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={cn("text-muted-foreground text-sm", className)}
data-slot="dialog-description"
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
data-slot="drawer-overlay"
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-background",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
data-slot="drawer-content"
{...props}
>
<div className="mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
data-slot="drawer-header"
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
data-slot="drawer-footer"
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
className={cn("font-semibold text-foreground", className)}
data-slot="drawer-title"
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
className={cn("text-muted-foreground text-sm", className)}
data-slot="drawer-description"
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
@@ -0,0 +1,256 @@
"use client";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "@workspace/ui/lib/utils";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
className={cn(
"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-inset={inset}
data-slot="dropdown-menu-item"
data-variant={variant}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-slot="dropdown-menu-checkbox-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-slot="dropdown-menu-radio-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
className={cn(
"px-2 py-1.5 font-medium text-sm data-[inset]:pl-8",
className
)}
data-inset={inset}
data-slot="dropdown-menu-label"
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
className={cn("-mx-1 my-1 h-px bg-border", className)}
data-slot="dropdown-menu-separator"
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
className={cn(
"ml-auto text-muted-foreground text-xs tracking-widest",
className
)}
data-slot="dropdown-menu-shortcut"
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-inset={inset}
data-slot="dropdown-menu-sub-trigger"
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
data-slot="dropdown-menu-sub-content"
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+103
View File
@@ -0,0 +1,103 @@
import { cn } from "@workspace/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12",
className
)}
data-slot="empty"
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
data-slot="empty-header"
{...props}
/>
);
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
);
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
className={cn(emptyMediaVariants({ variant, className }))}
data-slot="empty-icon"
data-variant={variant}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("font-medium text-lg tracking-tight", className)}
data-slot="empty-title"
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
className={cn(
"text-muted-foreground text-sm/relaxed [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
data-slot="empty-description"
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm",
className
)}
data-slot="empty-content"
{...props}
/>
);
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
};
+248
View File
@@ -0,0 +1,248 @@
"use client";
import { Label } from "@workspace/ui/components/label";
import { Separator } from "@workspace/ui/components/separator";
import { cn } from "@workspace/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import { useMemo } from "react";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
data-slot="field-set"
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
data-slot="field-legend"
data-variant={variant}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
data-slot="field-group"
{...props}
/>
);
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"@md/field-group:flex-row flex-col @md/field-group:items-center @md/field-group:[&>*]:w-auto [&>*]:w-full [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @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
className={cn(fieldVariants({ orientation }), className)}
data-orientation={orientation}
data-slot="field"
role="group"
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
data-slot="field-content"
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<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-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
className
)}
data-slot="field-label"
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex w-fit items-center gap-2 font-medium text-sm leading-snug group-data-[disabled=true]/field:opacity-50",
className
)}
data-slot="field-label"
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
className={cn(
"font-normal text-muted-foreground text-sm leading-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5 last:mt-0",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
data-slot="field-description"
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
className={cn(
"-my-2 group-data-[variant=outline]/field-group:-mb-2 relative h-5 text-sm",
className
)}
data-content={!!children}
data-slot="field-separator"
{...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
className={cn("font-normal text-destructive text-sm", className)}
data-slot="field-error"
role="alert"
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};
+163
View File
@@ -0,0 +1,163 @@
"use client";
import type * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import { Label } from "@workspace/ui/components/label";
import { cn } from "@workspace/ui/lib/utils";
import * as React from "react";
import {
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext,
useFormState,
} from "react-hook-form";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
className={cn("grid gap-2", className)}
data-slot="form-item"
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
className={cn("data-[error=true]:text-destructive", className)}
data-error={!!error}
data-slot="form-label"
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
aria-describedby={
error ? `${formDescriptionId} ${formMessageId}` : `${formDescriptionId}`
}
aria-invalid={!!error}
data-slot="form-control"
id={formItemId}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
className={cn("text-muted-foreground text-sm", className)}
data-slot="form-description"
id={formDescriptionId}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
className={cn("text-destructive text-sm", className)}
data-slot="form-message"
id={formMessageId}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
@@ -0,0 +1,100 @@
"use client";
import { cn } from "@workspace/ui/lib/utils";
import { motion } from "motion/react";
import type React from "react";
import { useEffect, useState } from "react";
type Direction = "TOP" | "LEFT" | "BOTTOM" | "RIGHT";
export function HoverBorderGradient({
children,
containerClassName,
className,
as: Tag = "button",
duration = 1,
clockwise = true,
...props
}: React.PropsWithChildren<
{
as?: React.ElementType;
containerClassName?: string;
className?: string;
duration?: number;
clockwise?: boolean;
} & React.HTMLAttributes<HTMLElement>
>) {
const [hovered, setHovered] = useState<boolean>(false);
const [direction, setDirection] = useState<Direction>("TOP");
const rotateDirection = (currentDirection: Direction): Direction => {
const directions: Direction[] = ["TOP", "LEFT", "BOTTOM", "RIGHT"];
const currentIndex = directions.indexOf(currentDirection);
const nextIndex = clockwise
? (currentIndex - 1 + directions.length) % directions.length
: (currentIndex + 1) % directions.length;
return directions[nextIndex] as Direction;
};
const movingMap: Record<Direction, string> = {
TOP: "radial-gradient(20.7% 50% at 50% 0%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)",
LEFT: "radial-gradient(16.6% 43.1% at 0% 50%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)",
BOTTOM:
"radial-gradient(20.7% 50% at 50% 100%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)",
RIGHT:
"radial-gradient(16.2% 41.199999999999996% at 100% 50%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)",
};
const highlight =
"radial-gradient(75% 181.15942028985506% at 50% 50%, #3275F8 0%, rgba(255, 255, 255, 0) 100%)";
useEffect(() => {
if (!hovered) {
const interval = setInterval(() => {
setDirection((prevState) => rotateDirection(prevState));
}, duration * 1000);
return () => clearInterval(interval);
}
}, [hovered]);
return (
<Tag
className={cn(
"relative flex h-min w-fit flex-col flex-nowrap content-center items-center justify-center gap-10 overflow-visible rounded-full border bg-black/20 decoration-clone p-px transition duration-500 hover:bg-black/10 dark:bg-white/20",
containerClassName
)}
onMouseEnter={(_event: React.MouseEvent<HTMLDivElement>) => {
setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
{...props}
>
<div
className={cn(
"z-10 w-auto rounded-[inherit] bg-black px-4 py-2 text-white",
className
)}
>
{children}
</div>
<motion.div
animate={{
background: hovered
? [movingMap[direction], highlight]
: movingMap[direction],
}}
className={cn(
"absolute inset-0 z-0 flex-none overflow-hidden rounded-[inherit]"
)}
initial={{ background: movingMap[direction] }}
style={{
filter: "blur(2px)",
position: "absolute",
width: "100%",
height: "100%",
}}
transition={{ ease: "linear", duration: duration ?? 1 }}
/>
<div className="absolute inset-[2px] z-1 flex-none rounded-[100px] bg-black" />
</Tag>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
align={align}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
data-slot="hover-card-content"
sideOffset={sideOffset}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import { Input } from "@workspace/ui/components/input";
import { Textarea } from "@workspace/ui/components/textarea";
import { cn } from "@workspace/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs outline-none transition-[color,box-shadow] dark:bg-input/30",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
// Error state.
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
data-slot="input-group"
role="group"
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 font-medium text-muted-foreground text-sm group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
"block-end":
"order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
},
},
defaultVariants: {
align: "inline-start",
},
}
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
className={cn(inputGroupAddonVariants({ align }), className)}
data-align={align}
data-slot="input-group-addon"
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
role="group"
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
className={cn(inputGroupButtonVariants({ size }), className)}
data-size={size}
type={type}
variant={variant}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-muted-foreground text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
data-slot="input-group-control"
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
data-slot="input-group-control"
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { cn } from "@workspace/ui/lib/utils";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import * as React from "react";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
className={cn("disabled:cursor-not-allowed", className)}
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
data-slot="input-otp"
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex items-center", className)}
data-slot="input-otp-group"
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
className={cn(
"relative flex h-9 w-9 items-center justify-center border-input border-y border-r text-sm shadow-xs outline-none transition-all first:rounded-l-md first:border-l last:rounded-r-md aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
className
)}
data-active={isActive}
data-slot="input-otp-slot"
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
+20
View File
@@ -0,0 +1,20 @@
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
data-slot="input"
type={type}
{...props}
/>
);
}
export { Input };
+192
View File
@@ -0,0 +1,192 @@
import { Slot } from "@radix-ui/react-slot";
import { Separator } from "@workspace/ui/components/separator";
import { cn } from "@workspace/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("group/item-group flex flex-col", className)}
data-slot="item-group"
role="list"
{...props}
/>
);
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
className={cn("my-0", className)}
data-slot="item-separator"
orientation="horizontal"
{...props}
/>
);
}
const itemVariants = cva(
"group/item flex flex-wrap items-center rounded-md border border-transparent text-sm outline-none transition-colors duration-100 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-accent/50",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "gap-4 p-4",
sm: "gap-2.5 px-4 py-3",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
className={cn(itemVariants({ variant, size, className }))}
data-size={size}
data-slot="item"
data-variant={variant}
{...props}
/>
);
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 rounded-sm border bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
);
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
className={cn(itemMediaVariants({ variant, className }))}
data-slot="item-media"
data-variant={variant}
{...props}
/>
);
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
data-slot="item-content"
{...props}
/>
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex w-fit items-center gap-2 font-medium text-sm leading-snug",
className
)}
data-slot="item-title"
{...props}
/>
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
className={cn(
"line-clamp-2 text-balance font-normal text-muted-foreground text-sm leading-normal",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
data-slot="item-description"
{...props}
/>
);
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex items-center gap-2", className)}
data-slot="item-actions"
{...props}
/>
);
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
data-slot="item-header"
{...props}
/>
);
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
data-slot="item-footer"
{...props}
/>
);
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
};
+28
View File
@@ -0,0 +1,28 @@
import { cn } from "@workspace/ui/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-sm bg-muted px-1 font-medium font-sans text-muted-foreground text-xs",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
data-slot="kbd"
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
className={cn("inline-flex items-center gap-1", className)}
data-slot="kbd-group"
{...props}
/>
);
}
export { Kbd, KbdGroup };
+23
View File
@@ -0,0 +1,23 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
className={cn(
"flex select-none items-center gap-2 font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
className
)}
data-slot="label"
{...props}
/>
);
}
export { Label };
+275
View File
@@ -0,0 +1,275 @@
"use client";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { cn } from "@workspace/ui/lib/utils";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
className={cn(
"flex h-9 items-center gap-1 rounded-md border bg-background p-1 shadow-xs",
className
)}
data-slot="menubar"
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
className={cn(
"flex select-none items-center rounded-sm px-2 py-1 font-medium text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
data-slot="menubar-trigger"
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
align={align}
alignOffset={alignOffset}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in",
className
)}
data-slot="menubar-content"
sideOffset={sideOffset}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenubarPrimitive.Item
className={cn(
"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-inset={inset}
data-slot="menubar-item"
data-variant={variant}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-slot="menubar-checkbox-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-slot="menubar-radio-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
className={cn(
"px-2 py-1.5 font-medium text-sm data-[inset]:pl-8",
className
)}
data-inset={inset}
data-slot="menubar-label"
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
className={cn("-mx-1 my-1 h-px bg-border", className)}
data-slot="menubar-separator"
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
className={cn(
"ml-auto text-muted-foreground text-xs tracking-widest",
className
)}
data-slot="menubar-shortcut"
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground",
className
)}
data-inset={inset}
data-slot="menubar-sub-trigger"
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
data-slot="menubar-sub-content"
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};
@@ -0,0 +1,167 @@
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cn } from "@workspace/ui/lib/utils";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import type * as React from "react";
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
data-slot="navigation-menu"
data-viewport={viewport}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
data-slot="navigation-menu-list"
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
className={cn("relative", className)}
data-slot="navigation-menu-item"
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 font-medium text-sm outline-none transition-[color,box-shadow] hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:hover:bg-accent"
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
className={cn(navigationMenuTriggerStyle(), "group", className)}
data-slot="navigation-menu-trigger"
{...props}
>
{children}{" "}
<ChevronDownIcon
aria-hidden="true"
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
className={cn(
"data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 **:data-[slot=navigation-menu-link]:focus:outline-none **:data-[slot=navigation-menu-link]:focus:ring-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in",
className
)}
data-slot="navigation-menu-content"
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
className={cn(
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full origin-top-center overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=closed]:animate-out data-[state=open]:animate-in md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
data-slot="navigation-menu-viewport"
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
className={cn(
"flex flex-col gap-1 rounded-sm p-2 text-sm outline-none transition-all hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
data-slot="navigation-menu-link"
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
className={cn(
"data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=visible]:animate-in",
className
)}
data-slot="navigation-menu-indicator"
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};
+126
View File
@@ -0,0 +1,126 @@
import { type Button, buttonVariants } from "@workspace/ui/components/button";
import { cn } from "@workspace/ui/lib/utils";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react";
import type * as React from "react";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
data-slot="pagination"
role="navigation"
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
className={cn("flex flex-row items-center gap-1", className)}
data-slot="pagination-content"
{...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 (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
data-active={isActive}
data-slot="pagination-link"
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
size="default"
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
size="default"
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
className={cn("flex size-9 items-center justify-center", className)}
data-slot="pagination-ellipsis"
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};
+47
View File
@@ -0,0 +1,47 @@
"use client";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
data-slot="popover-content"
sideOffset={sideOffset}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+30
View File
@@ -0,0 +1,30 @@
"use client";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
data-slot="progress"
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
data-slot="progress-indicator"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };
@@ -0,0 +1,44 @@
"use client";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { cn } from "@workspace/ui/lib/utils";
import { CircleIcon } from "lucide-react";
import type * as React from "react";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-3", className)}
data-slot="radio-group"
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
className={cn(
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
data-slot="radio-group-item"
{...props}
>
<RadioGroupPrimitive.Indicator
className="relative flex items-center justify-center"
data-slot="radio-group-indicator"
>
<CircleIcon className="-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 size-2 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { cn } from "@workspace/ui/lib/utils";
import { GripVerticalIcon } from "lucide-react";
import type * as React from "react";
import * as ResizablePrimitive from "react-resizable-panels";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
data-slot="resizable-panel-group"
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"after:-translate-x-1/2 data-[panel-group-direction=vertical]:after:-translate-y-1/2 relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
data-slot="resizable-handle"
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-xs border bg-border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
@@ -0,0 +1,57 @@
"use client";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
className={cn("relative", className)}
data-slot="scroll-area"
{...props}
>
<ScrollAreaPrimitive.Viewport
className="size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50"
data-slot="scroll-area-viewport"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
className={cn(
"flex touch-none select-none p-px transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
data-slot="scroll-area-scrollbar"
orientation={orientation}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
className="relative flex-1 rounded-full bg-border"
data-slot="scroll-area-thumb"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
+186
View File
@@ -0,0 +1,186 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { cn } from "@workspace/ui/lib/utils";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import type * as React from "react";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
className={cn(
"flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[size=default]:h-9 data-[size=sm]:h-8 data-[placeholder]:text-muted-foreground *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:hover:bg-input/50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-size={size}
data-slot="select-trigger"
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
align={align}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
position === "popper" &&
"data-[side=left]:-translate-x-1 data-[side=top]:-translate-y-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1",
className
)}
data-slot="select-content"
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
className={cn("px-2 py-1.5 text-muted-foreground text-xs", className)}
data-slot="select-label"
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
className={cn(
"relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
data-slot="select-item"
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
className={cn("-mx-1 pointer-events-none my-1 h-px bg-border", className)}
data-slot="select-separator"
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
data-slot="select-scroll-up-button"
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
data-slot="select-scroll-down-button"
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+27
View File
@@ -0,0 +1,27 @@
"use client";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
className
)}
data-slot="separator"
decorative={decorative}
orientation={orientation}
{...props}
/>
);
}
export { Separator };
+138
View File
@@ -0,0 +1,138 @@
"use client";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cn } from "@workspace/ui/lib/utils";
import { XIcon } from "lucide-react";
import type * as React from "react";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
data-slot="sheet-overlay"
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
data-slot="sheet-content"
{...props}
>
{children}
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col gap-1.5 p-4", className)}
data-slot="sheet-header"
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
data-slot="sheet-footer"
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
className={cn("font-semibold text-foreground", className)}
data-slot="sheet-title"
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
className={cn("text-muted-foreground text-sm", className)}
data-slot="sheet-description"
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+728
View File
@@ -0,0 +1,728 @@
"use client";
import { Slot } from "@radix-ui/react-slot";
import { Button } from "@workspace/ui/components/button";
import { Input } from "@workspace/ui/components/input";
import { Separator } from "@workspace/ui/components/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@workspace/ui/components/sheet";
import { Skeleton } from "@workspace/ui/components/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { useIsMobile } from "@workspace/ui/hooks/use-mobile";
import { cn } from "@workspace/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import * as React from "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(
() =>
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}>
<TooltipProvider delayDuration={0}>
<div
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...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
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
data-slot="sidebar"
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
<SheetContent
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
data-mobile="true"
data-sidebar="sidebar"
data-slot="sidebar"
side={side}
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
>
<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-collapsible={state === "collapsed" ? collapsible : ""}
data-side={side}
data-slot="sidebar"
data-state={state}
data-variant={variant}
>
{/* This is what handles the sidebar gap on desktop */}
<div
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)"
)}
data-slot="sidebar-gap"
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// 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
)}
data-slot="sidebar-container"
{...props}
>
<div
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
data-sidebar="sidebar"
data-slot="sidebar-inner"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
className={cn("size-7", className)}
data-sidebar="trigger"
data-slot="sidebar-trigger"
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
size="icon"
variant="ghost"
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
aria-label="Toggle Sidebar"
className={cn(
"-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex",
"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 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
data-sidebar="rail"
data-slot="sidebar-rail"
onClick={toggleSidebar}
tabIndex={-1}
title="Toggle Sidebar"
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 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",
className
)}
data-slot="sidebar-inset"
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
className={cn("h-8 w-full bg-background shadow-none", className)}
data-sidebar="input"
data-slot="sidebar-input"
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col gap-2 p-2", className)}
data-sidebar="header"
data-slot="sidebar-header"
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col gap-2 p-2", className)}
data-sidebar="footer"
data-slot="sidebar-footer"
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
className={cn("mx-2 w-auto bg-sidebar-border", className)}
data-sidebar="separator"
data-slot="sidebar-separator"
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
data-sidebar="content"
data-slot="sidebar-content"
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
data-sidebar="group"
data-slot="sidebar-group"
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
data-sidebar="group-label"
data-slot="sidebar-group-label"
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:-inset-2 after:absolute md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
data-sidebar="group-action"
data-slot="sidebar-group-action"
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("w-full text-sm", className)}
data-sidebar="group-content"
data-slot="sidebar-group-content"
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
data-sidebar="menu"
data-slot="sidebar-menu"
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
className={cn("group/menu-item relative", className)}
data-sidebar="menu-item"
data-slot="sidebar-menu-item"
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] 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 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(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({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
data-active={isActive}
data-sidebar="menu-button"
data-size={size}
data-slot="sidebar-menu-button"
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
align="center"
hidden={state !== "collapsed" || isMobile}
side="right"
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:-inset-2 after:absolute md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
data-sidebar="menu-action"
data-slot="sidebar-menu-action"
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
data-sidebar="menu-badge"
data-slot="sidebar-menu-badge"
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(
() => `${Math.floor(Math.random() * 40) + 50}%`,
[]
);
return (
<div
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
data-sidebar="menu-skeleton"
data-slot="sidebar-menu-skeleton"
{...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
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
data-sidebar="menu-sub"
data-slot="sidebar-menu-sub"
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
className={cn("group/menu-sub-item relative", className)}
data-sidebar="menu-sub-item"
data-slot="sidebar-menu-sub-item"
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
className={cn(
"-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring 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 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
data-active={isActive}
data-sidebar="menu-sub-button"
data-size={size}
data-slot="sidebar-menu-sub-button"
{...props}
/>
);
}
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 "@workspace/ui/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("animate-pulse rounded-md bg-accent", className)}
data-slot="skeleton"
{...props}
/>
);
}
export { Skeleton };
+62
View File
@@ -0,0 +1,62 @@
"use client";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@workspace/ui/lib/utils";
import * as React from "react";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
);
return (
<SliderPrimitive.Root
className={cn(
"relative flex w-full touch-none select-none items-center data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[disabled]:opacity-50",
className
)}
data-slot="slider"
defaultValue={defaultValue}
max={max}
min={min}
value={value}
{...props}
>
<SliderPrimitive.Track
className={cn(
"relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5"
)}
data-slot="slider-track"
>
<SliderPrimitive.Range
className={cn(
"absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
data-slot="slider-range"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
className="block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:outline-hidden focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
data-slot="slider-thumb"
key={index}
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };
+40
View File
@@ -0,0 +1,40 @@
"use client";
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
theme={theme as ToasterProps["theme"]}
{...props}
/>
);
};
export { Toaster };
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@workspace/ui/lib/utils";
import { Loader2Icon } from "lucide-react";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
role="status"
{...props}
/>
);
}
export { Spinner };
+30
View File
@@ -0,0 +1,30 @@
"use client";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
className={cn(
"peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
data-slot="switch"
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block size-4 rounded-full bg-background ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
data-slot="switch-thumb"
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
className="relative w-full overflow-x-auto"
data-slot="table-container"
>
<table
className={cn("w-full caption-bottom text-sm", className)}
data-slot="table"
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
className={cn("[&_tr]:border-b", className)}
data-slot="table-header"
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
className={cn("[&_tr:last-child]:border-0", className)}
data-slot="table-body"
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
data-slot="table-footer"
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
data-slot="table-row"
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
className={cn(
"h-10 whitespace-nowrap px-2 text-left align-middle font-medium text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
data-slot="table-head"
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
className={cn(
"whitespace-nowrap p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
data-slot="table-cell"
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
className={cn("mt-4 text-muted-foreground text-sm", className)}
data-slot="table-caption"
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+65
View File
@@ -0,0 +1,65 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
className={cn("flex flex-col gap-2", className)}
data-slot="tabs"
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
className={cn(
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
className
)}
data-slot="tabs-list"
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn(
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 font-medium text-foreground text-sm transition-[color,box-shadow] focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:shadow-sm dark:text-muted-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-slot="tabs-trigger"
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
className={cn("flex-1 outline-none", className)}
data-slot="tabs-content"
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };
@@ -0,0 +1,58 @@
"use client";
import { cn } from "@workspace/ui/lib/utils";
import { motion, stagger, useAnimate } from "motion/react";
import { useEffect } from "react";
export const TextGenerateEffect = ({
words,
className,
filter = true,
duration = 0.5,
}: {
words: string;
className?: string;
filter?: boolean;
duration?: number;
}) => {
const [scope, animate] = useAnimate();
const wordsArray = words.split(" ");
useEffect(() => {
animate(
"span",
{
opacity: 1,
filter: filter ? "blur(0px)" : "none",
},
{
duration: duration ? duration : 1,
delay: stagger(0.2),
}
);
}, [scope.current]);
const renderWords = () => (
<motion.div ref={scope}>
{wordsArray.map((word, idx) => (
<motion.span
className="text-black opacity-0 dark:text-white"
key={word + idx}
style={{
filter: filter ? "blur(10px)" : "none",
}}
>
{word}{" "}
</motion.span>
))}
</motion.div>
);
return (
<div className={cn("font-bold", className)}>
<div className="mt-4">
<div className="text-2xl text-black leading-snug tracking-wide dark:text-white">
{renderWords()}
</div>
</div>
</div>
);
};
+17
View File
@@ -0,0 +1,17 @@
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
className={cn(
"field-sizing-content flex min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
data-slot="textarea"
{...props}
/>
);
}
export { Textarea };
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { motion, useScroll, useTransform } from "motion/react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
interface TimelineEntry {
title: string;
content: React.ReactNode;
}
export const Timeline = ({ data }: { data: TimelineEntry[] }) => {
const ref = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
setHeight(rect.height);
}
}, [ref]);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start 10%", "end 50%"],
});
const heightTransform = useTransform(scrollYProgress, [0, 1], [0, height]);
const opacityTransform = useTransform(scrollYProgress, [0, 0.1], [0, 1]);
return (
<div
className="w-full bg-white font-sans md:px-10 dark:bg-neutral-950"
ref={containerRef}
>
<div className="mx-auto max-w-7xl px-4 py-20 md:px-8 lg:px-10">
<h2 className="mb-4 max-w-4xl text-black text-lg md:text-4xl dark:text-white">
Changelog from my journey
</h2>
<p className="max-w-sm text-neutral-700 text-sm md:text-base dark:text-neutral-300">
I&apos;ve been working on Aceternity for the past 2 years. Here&apos;s
a timeline of my journey.
</p>
</div>
<div className="relative mx-auto max-w-7xl pb-20" ref={ref}>
{data.map((item, index) => (
<div
className="flex justify-start pt-10 md:gap-10 md:pt-40"
key={index}
>
<div className="sticky top-40 z-40 flex max-w-xs flex-col items-center self-start md:w-full md:flex-row lg:max-w-sm">
<div className="absolute left-3 flex h-10 w-10 items-center justify-center rounded-full bg-white md:left-3 dark:bg-black">
<div className="h-4 w-4 rounded-full border border-neutral-300 bg-neutral-200 p-2 dark:border-neutral-700 dark:bg-neutral-800" />
</div>
<h3 className="hidden font-bold text-neutral-500 text-xl md:block md:pl-20 md:text-5xl dark:text-neutral-500">
{item.title}
</h3>
</div>
<div className="relative w-full pr-4 pl-20 md:pl-4">
<h3 className="mb-4 block text-left font-bold text-2xl text-neutral-500 md:hidden dark:text-neutral-500">
{item.title}
</h3>
{item.content}{" "}
</div>
</div>
))}
<div
className="absolute top-0 left-8 w-[2px] overflow-hidden bg-[linear-gradient(to_bottom,var(--tw-gradient-stops))] from-[0%] from-transparent via-neutral-200 to-[99%] to-transparent [mask-image:linear-gradient(to_bottom,transparent_0%,black_10%,black_90%,transparent_100%)] md:left-8 dark:via-neutral-700"
style={{
height: `${height}px`,
}}
>
<motion.div
className="absolute inset-x-0 top-0 w-[2px] rounded-full bg-gradient-to-t from-[0%] from-purple-500 via-[10%] via-blue-500 to-transparent"
style={{
height: heightTransform,
opacity: opacityTransform,
}}
/>
</div>
</div>
</div>
);
};
@@ -0,0 +1,82 @@
"use client";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { toggleVariants } from "@workspace/ui/components/toggle";
import { cn } from "@workspace/ui/lib/utils";
import type { VariantProps } from "class-variance-authority";
import * as React from "react";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number;
}
>({
size: "default",
variant: "default",
spacing: 0,
});
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number;
}) {
return (
<ToggleGroupPrimitive.Root
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
data-size={size}
data-slot="toggle-group"
data-spacing={spacing}
data-variant={variant}
style={{ "--gap": spacing } as React.CSSProperties}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:first:border-l data-[spacing=0]:first:rounded-l-md",
className
)}
data-size={context.size || size}
data-slot="toggle-group-item"
data-spacing={context.spacing}
data-variant={context.variant || variant}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };
+46
View File
@@ -0,0 +1,46 @@
"use client";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cn } from "@workspace/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[color,box-shadow] hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
className={cn(toggleVariants({ variant, size, className }))}
data-slot="toggle"
{...props}
/>
);
}
export { Toggle, toggleVariants };
+60
View File
@@ -0,0 +1,60 @@
"use client";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@workspace/ui/lib/utils";
import type * as React from "react";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className={cn(
"fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out",
className
)}
data-slot="tooltip-content"
sideOffset={sideOffset}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,134 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@workspace/ui/components/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { Icon } from "@workspace/ui/composed/icon";
import { cn } from "@workspace/ui/lib/utils";
import { countries, type ICountry } from "@workspace/ui/utils/countries";
import { BoxIcon, Check, ChevronsUpDown } from "lucide-react";
import { useEffect, useState } from "react";
interface AreaCodeSelectProps {
value?: string;
onChange?: (value: ICountry) => void;
className?: string;
placeholder?: string;
simple?: boolean;
whitelist?: string[];
}
const filterItems = (whitelist?: string[]) => {
const baseItems = countries
.filter((item) => !!item.phone)
.flatMap((item) => {
const phones = item.phone!.split(",");
if (phones.length > 1) {
return [...phones].map((phone) => ({
...item,
phone,
}));
}
return item;
});
if (!whitelist?.length) return baseItems;
return baseItems.filter((item) => whitelist.includes(item.phone!));
};
export const AreaCodeSelect = ({
value,
onChange,
className,
placeholder = "Select Area Code",
simple = false,
whitelist,
}: AreaCodeSelectProps) => {
const [open, setOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<ICountry | undefined>();
const items = filterItems(whitelist);
useEffect(() => {
if (value !== selectedItem?.phone) {
const found = items.find((item) => item.phone === value);
setSelectedItem(found);
}
}, [selectedItem?.phone, value, items]);
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<Button
aria-expanded={open}
className={cn("justify-between", className)}
role="combobox"
variant="outline"
>
{selectedItem ? (
<div className="flex items-center gap-2">
<Icon
className="!size-5"
icon={`flagpack:${selectedItem.alpha2.toLowerCase()}`}
/>
+{selectedItem.phone}
{!simple && `(${selectedItem.name})`}
</div>
) : (
placeholder
)}
<ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="p-0">
<Command>
<CommandInput placeholder="Search area code..." />
<CommandList>
<CommandEmpty>
<BoxIcon className="inline-block text-slate-500" />
</CommandEmpty>
<CommandGroup>
{items.map((item) => (
<CommandItem
key={`${item.alpha2}-${item.phone}`}
onSelect={() => {
setSelectedItem(item);
onChange?.(item);
setOpen(false);
}}
value={`${item.phone}-${item.name}`}
>
<div className="flex items-center gap-2">
<Icon
className="!size-5"
icon={`flagpack:${item.alpha2.toLowerCase()}`}
/>
+{item.phone} ({item.name})
</div>
<Check
className={cn(
"ml-auto h-4 w-4",
selectedItem?.phone === item.phone
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@workspace/ui/components/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { cn } from "@workspace/ui/lib/utils";
import { BoxIcon, CheckIcon, ChevronsUpDownIcon } from "lucide-react";
import * as React from "react";
export type Option<T = string> = {
value: T;
label: string;
children?: React.ReactNode;
};
// Conditional types to determine the value type for onChange
type OnChangeType<T, M extends boolean> = M extends true ? T[] : T;
type ComboboxProps<T = string, M extends boolean = false> = {
multiple?: M;
options?: Option<T>[];
placeholder?: string;
value?: OnChangeType<T, M>;
onChange: (value: OnChangeType<T, M>) => void;
className?: string;
};
export function Combobox<T, M extends boolean = false>({
multiple = false as M,
options = [],
placeholder = "Select...",
value,
onChange,
className,
}: ComboboxProps<T, M>) {
const [open, setOpen] = React.useState(false);
const handleSelect = (selectedValue: T) => {
if (multiple) {
const newValue = Array.isArray(value) ? [...value] : [];
if (newValue.includes(selectedValue)) {
newValue.splice(newValue.indexOf(selectedValue), 1);
onChange(newValue as OnChangeType<T, M>);
} else {
onChange([...newValue, selectedValue] as OnChangeType<T, M>);
}
} else {
const newValue = selectedValue === value ? ("" as T) : selectedValue;
onChange(newValue as OnChangeType<T, M>);
setOpen(false);
}
};
const renderButtonLabel = () => {
if (multiple && Array.isArray(value) && value.length > 0) {
const selectedLabels = options
.filter((option) => value.includes(option.value))
.map((option) => option.label)
.join(", ");
return selectedLabels;
}
if (!multiple) {
const selectedOption = options.find((option) => option.value === value);
return selectedOption
? selectedOption.children || selectedOption.label
: placeholder;
}
return placeholder;
};
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<Button
aria-expanded={open}
className={cn("w-full items-center justify-between", className)}
role="combobox"
variant="outline"
>
<span className="truncate">{renderButtonLabel()}</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-fit p-0">
<Command>
<CommandInput className="h-9" placeholder="Search..." />
<CommandEmpty>
<BoxIcon className="inline-block text-slate-500" />
</CommandEmpty>
<CommandGroup>
<CommandList>
{options.map((option) => (
<CommandItem
key={String(option.label + option.value)}
onSelect={() => handleSelect(option.value)}
value={option.label + option.value}
>
{option.children || option.label}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
multiple
? Array.isArray(value) && value.includes(option.value)
? "opacity-100"
: "opacity-0"
: value === option.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandList>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,47 @@
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import type React from "react";
import type { ReactNode } from "react";
interface ConfirmationButtonProps {
trigger: ReactNode;
title: string;
description: string;
onConfirm: () => void | Promise<void>;
cancelText?: string;
confirmText?: string;
}
export const ConfirmButton: React.FC<ConfirmationButtonProps> = ({
trigger,
title,
description,
onConfirm,
cancelText = "Cancel",
confirmText = "Confirm",
}) => (
<AlertDialog>
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>{confirmText}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import { Calendar } from "@workspace/ui/components/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { cn } from "@workspace/ui/lib/utils";
import { intlFormat } from "date-fns";
import { CalendarIcon } from "lucide-react";
import * as React from "react";
import type { DayPicker } from "react-day-picker";
export function DatePicker({
placeholder,
value,
onChange,
...props
}: React.ComponentProps<typeof DayPicker> & {
placeholder?: string;
value?: number;
onChange?: (value?: number) => void;
}) {
const [date, setDate] = React.useState<Date | undefined>(
value ? new Date(value) : undefined
);
const handleSelect = (selectedDate: Date | undefined) => {
setDate(selectedDate);
if (onChange) {
onChange(selectedDate?.getTime() || 0);
}
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
className={cn(
"w-full justify-between font-normal",
!value && "text-muted-foreground"
)}
variant="outline"
>
{value ? intlFormat(value) : <span>{placeholder}</span>}
<CalendarIcon className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
{...props}
initialFocus
mode="single"
onSelect={handleSelect}
selected={date}
/>
</PopoverContent>
</Popover>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { Button } from "@workspace/ui/components/button";
import { Label } from "@workspace/ui/components/label";
import { Switch } from "@workspace/ui/components/switch";
import { Textarea } from "@workspace/ui/components/textarea";
import { Combobox } from "@workspace/ui/composed/combobox";
import {
EnhancedInput,
type EnhancedInputProps,
} from "@workspace/ui/composed/enhanced-input";
import { cn } from "@workspace/ui/lib/utils";
import { CircleMinusIcon, CirclePlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
interface FieldConfig extends Omit<EnhancedInputProps, "type"> {
name: string;
type: "text" | "number" | "select" | "time" | "boolean" | "textarea";
options?: { label: string; value: string }[];
// optional per-item visibility function: returns true to show the field for the given item
visible?: (item: Record<string, any>) => boolean;
}
interface ObjectInputProps<T> {
value: T;
onChange: (value: T) => void;
fields: FieldConfig[];
className?: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function ObjectInput<T extends Record<string, any>>({
value,
onChange,
fields,
className,
}: ObjectInputProps<T>) {
const [internalState, setInternalState] = useState<T>(value);
useEffect(() => {
setInternalState(value);
}, [value]);
const updateField = (key: keyof T, fieldValue: string | number | boolean) => {
const updatedInternalState = { ...internalState, [key]: fieldValue };
setInternalState(updatedInternalState);
onChange(updatedInternalState);
};
const renderField = (field: FieldConfig) => {
// if visible callback exists and returns false for current item, don't render
if (field.visible && !field.visible(internalState)) return null;
switch (field.type) {
case "select":
return (
field.options && (
<Combobox<string, false>
onChange={(fieldValue) => updateField(field.name, fieldValue)}
options={field.options}
placeholder={field.placeholder}
value={internalState[field.name]}
/>
)
);
case "boolean":
return (
<div className="flex h-full items-center space-x-2">
<Switch
checked={internalState[field.name] as boolean}
onCheckedChange={(fieldValue) =>
updateField(field.name, fieldValue)
}
/>
{field.placeholder && <Label>{field.placeholder}</Label>}
</div>
);
case "textarea":
return (
<div className="w-full space-y-2">
{field.prefix && (
<Label className="font-medium text-sm">{field.prefix}</Label>
)}
<Textarea
className="min-h-32"
onChange={(e) => updateField(field.name, e.target.value)}
placeholder={field.placeholder}
value={internalState[field.name] || ""}
/>
</div>
);
default:
return (
<EnhancedInput
onValueChange={(fieldValue) => updateField(field.name, fieldValue)}
value={internalState[field.name]}
{...field}
/>
);
}
};
return (
<div className={cn("flex flex-1 flex-wrap gap-4", className)}>
{fields.map((field) => {
const node = renderField(field);
if (node === null) return null; // don't render wrapper if field hidden
return (
<div className={cn("flex-1", field.className)} key={field.name}>
{node}
</div>
);
})}
</div>
);
}
interface ArrayInputProps<T> {
value?: T[];
onChange: (value: T[]) => void;
fields: FieldConfig[];
isReverse?: boolean;
className?: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function ArrayInput<T extends Record<string, any>>({
value = [],
onChange,
fields,
isReverse = false,
className,
}: ArrayInputProps<T>) {
const initializeDefaultItem = (): T =>
fields.reduce((acc, field) => {
acc[field.name as keyof T] = undefined as T[keyof T];
return acc;
}, {} as T);
const [displayItems, setDisplayItems] = useState<T[]>(() =>
value.length > 0 ? value : [initializeDefaultItem()]
);
const isItemModified = (item: T): boolean =>
fields.some((field) => {
const val = item[field.name];
return val !== undefined && val !== null && val !== "";
});
const handleItemChange = (index: number, updatedItem: T) => {
const newDisplayItems = [...displayItems];
newDisplayItems[index] = updatedItem;
setDisplayItems(newDisplayItems);
const modifiedItems = newDisplayItems.filter(isItemModified);
onChange(modifiedItems);
};
const createField = () => {
if (isReverse) {
setDisplayItems([initializeDefaultItem(), ...displayItems]);
} else {
setDisplayItems([...displayItems, initializeDefaultItem()]);
}
};
const deleteField = (index: number) => {
const newDisplayItems = displayItems.filter((_, i) => i !== index);
setDisplayItems(newDisplayItems);
const modifiedItems = newDisplayItems.filter(isItemModified);
onChange(modifiedItems);
};
useEffect(() => {
if (value.length > 0) {
setDisplayItems(value);
}
}, [value]);
return (
<div className="flex flex-col gap-4">
{displayItems.map((item, index) => (
<div className="flex items-center gap-4" key={index}>
<ObjectInput
className={className}
fields={fields}
onChange={(updatedItem) => handleItemChange(index, updatedItem)}
value={item}
/>
<div className="flex min-w-20 items-center">
{displayItems.length > 1 && (
<Button
className="p-0 text-destructive text-lg"
onClick={() => deleteField(index)}
size="icon"
type="button"
variant="ghost"
>
<CircleMinusIcon />
</Button>
)}
{(isReverse ? index === 0 : index === displayItems.length - 1) && (
<Button
className="p-0 text-lg text-primary"
onClick={createField}
size="icon"
type="button"
variant="ghost"
>
<CirclePlusIcon />
</Button>
)}
</div>
</div>
))}
</div>
);
}
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
"use client";
import {
MonacoEditor,
type MonacoEditorProps,
} from "@workspace/ui/composed/editor/monaco-editor";
import { useEffect, useRef } from "react";
export function HTMLEditor(props: MonacoEditorProps) {
return (
<MonacoEditor
description="Support HTML"
title="HTML Editor"
{...props}
language="markdown"
render={(value) => <HTMLPreview value={value} />}
/>
);
}
interface HTMLPreviewProps {
value?: string;
}
function HTMLPreview({ value }: HTMLPreviewProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
const iframeDocument = iframeRef.current?.contentDocument;
if (iframeDocument) {
iframeDocument.open();
iframeDocument.write(value || "");
iframeDocument.close();
}
}, [value]);
return (
<iframe
className="h-full w-full border-0"
ref={iframeRef}
title="HTML Preview"
/>
);
}
@@ -0,0 +1,5 @@
export { GoTemplateEditor } from "@workspace/ui/composed/editor/go-template";
export { HTMLEditor } from "@workspace/ui/composed/editor/html";
export { JSONEditor } from "@workspace/ui/composed/editor/json";
export { MarkdownEditor } from "@workspace/ui/composed/editor/markdown";
export { MonacoEditor } from "@workspace/ui/composed/editor/monaco-editor";
+102
View File
@@ -0,0 +1,102 @@
"use client";
import {
MonacoEditor,
type MonacoEditorProps,
} from "@workspace/ui/composed/editor/monaco-editor";
import { useMemo } from "react";
interface JSONEditorProps
extends Omit<MonacoEditorProps, "placeholder" | "value" | "onChange"> {
schema?: Record<string, unknown>;
placeholder?: Record<string, unknown>;
value?: Record<string, unknown> | string;
onChange?: (value: Record<string, unknown> | string | undefined) => void;
}
export function JSONEditor(props: JSONEditorProps) {
const { schema, placeholder = {}, ...rest } = props;
const editorKey = useMemo(
() => JSON.stringify({ schema, placeholder }),
[schema, placeholder]
);
return (
<MonacoEditor
key={editorKey}
title="Edit JSON"
{...rest}
language="json"
onChange={(value) => {
if (props.onChange && typeof value === "string") {
try {
props.onChange(
props.value && typeof props.value === "string"
? value
: JSON.parse(value)
);
} catch (e) {
console.log("Invalid JSON input:", e);
}
}
}}
onMount={(editor: any, monaco: any) => {
if (props.onMount) props.onMount(editor, monaco);
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemas: [
{
uri: "",
fileMatch: ["*"],
schema: schema || {
type: "object",
properties: generateSchema(placeholder),
},
},
],
});
}}
placeholder={placeholder ? JSON.stringify(placeholder, null, 2) : ""}
value={
typeof props.value === "string"
? props.value
: props.value
? JSON.stringify(props.value, null, 2)
: ""
}
/>
);
}
const generateSchema = (
obj: Record<string, unknown>
): Record<string, SchemaProperty> => {
const properties: Record<string, SchemaProperty> = {};
for (const [key, value] of Object.entries(obj)) {
if (Array.isArray(value)) {
properties[key] = {
type: "array",
items:
value.length > 0
? generateSchema({ item: value[0] }).item
: { type: "null" },
};
} else if (typeof value === "object" && value !== null) {
properties[key] = {
type: "object",
properties: generateSchema(value as Record<string, unknown>),
};
} else {
properties[key] = { type: typeof value as SchemaType };
}
}
return properties;
};
type SchemaType = "string" | "number" | "boolean" | "object" | "array" | "null";
interface SchemaProperty {
type: SchemaType;
items?: SchemaProperty;
properties?: Record<string, SchemaProperty>;
}
@@ -0,0 +1,19 @@
"use client";
import {
MonacoEditor,
type MonacoEditorProps,
} from "@workspace/ui/composed/editor/monaco-editor";
import { Markdown } from "@workspace/ui/composed/markdown";
export function MarkdownEditor(props: MonacoEditorProps) {
return (
<MonacoEditor
description="Support markdwon and html syntax"
title="Markdown Editor"
{...props}
language="markdown"
render={(value) => <Markdown>{value || ""}</Markdown>}
/>
);
}
@@ -0,0 +1,213 @@
"use client";
import { Editor, type Monaco, type OnMount } from "@monaco-editor/react";
import { Button } from "@workspace/ui/components/button";
import { cn } from "@workspace/ui/lib/utils";
import { useSize } from "ahooks";
import { EyeIcon, EyeOff, FullscreenIcon, MinimizeIcon } from "lucide-react";
import DraculaTheme from "monaco-themes/themes/Dracula.json" with {
type: "json",
};
import { useEffect, useRef, useState } from "react";
export interface MonacoEditorProps {
value?: string;
onChange?: (value: string | undefined) => void;
onBlur?: (value: string | undefined) => void;
title?: string;
description?: string;
placeholder?: string;
render?: (value?: string) => React.ReactNode;
onMount?: OnMount;
beforeMount?: (monaco: Monaco) => void;
language?: string;
className?: string;
showLineNumbers?: boolean;
readOnly?: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debounce<T extends (...args: any[]) => void>(func: T, delay: number) {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
}
export function MonacoEditor({
value: propValue,
onChange,
onBlur,
title = "Editor Title",
description,
placeholder = "Start typing...",
render,
onMount,
beforeMount,
language = "markdown",
className,
showLineNumbers = false,
readOnly = false,
}: MonacoEditorProps) {
const [internalValue, setInternalValue] = useState<string | undefined>(
propValue
);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const size = useSize(ref);
useEffect(() => {
setInternalValue(propValue);
}, [propValue]);
const debouncedOnChange = useRef(
debounce((newValue: string | undefined) => {
if (onChange) {
onChange(newValue);
}
}, 300)
).current;
const handleEditorDidMount: OnMount = (editor, monaco) => {
if (onMount) onMount(editor, monaco);
editor.onDidChangeModelContent(() => {
const newValue = editor.getValue();
setInternalValue(newValue);
debouncedOnChange(newValue);
});
editor.onDidBlurEditorWidget(() => {
if (onBlur) {
onBlur(editor.getValue());
}
});
};
const toggleFullscreen = () => setIsFullscreen(!isFullscreen);
const togglePreview = () => setIsPreviewVisible(!isPreviewVisible);
return (
<div className="size-full" ref={ref}>
<div style={size}>
<div
className={cn(
"flex size-full min-h-96 flex-col rounded-md border",
className,
{
"!mt-0 fixed inset-0 z-50 h-screen bg-background": isFullscreen,
}
)}
>
<div className="flex items-center justify-between border-b p-2">
<div>
<h1 className="text-left font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{title}
</h1>
<p className="text-[0.8rem] text-muted-foreground">
{description}
</p>
</div>
<div className="flex items-center space-x-2">
{render && (
<Button
onClick={togglePreview}
size="icon"
type="button"
variant="outline"
>
{isPreviewVisible ? <EyeOff /> : <EyeIcon />}
</Button>
)}
<Button
onClick={toggleFullscreen}
size="icon"
type="button"
variant="outline"
>
{isFullscreen ? <MinimizeIcon /> : <FullscreenIcon />}
</Button>
</div>
</div>
<div className={cn("relative flex flex-1")}>
<div
className={cn("flex-1 overflow-auto p-4 invert dark:invert-0", {
"w-1/2": isPreviewVisible,
})}
>
<Editor
beforeMount={(monaco: Monaco) => {
monaco.editor.defineTheme("transparentTheme", {
base: DraculaTheme.base as "vs" | "vs-dark" | "hc-black",
inherit: DraculaTheme.inherit,
rules: DraculaTheme.rules,
colors: {
...DraculaTheme.colors,
"editor.background": "#00000000",
},
});
if (beforeMount) {
beforeMount(monaco);
}
}}
className=""
language={language}
onChange={(newValue) => {
setInternalValue(newValue);
debouncedOnChange(newValue);
}}
onMount={handleEditorDidMount}
options={{
automaticLayout: true,
contextmenu: false,
folding: false,
fontSize: 14,
formatOnPaste: true,
formatOnType: true,
glyphMargin: false,
lineNumbers: showLineNumbers ? "on" : "off",
minimap: { enabled: false },
overviewRulerLanes: 0,
renderLineHighlight: "none",
scrollBeyondLastLine: false,
scrollbar: {
useShadows: false,
vertical: "auto",
},
tabSize: 2,
wordWrap: "off",
readOnly,
}}
theme="transparentTheme"
value={internalValue}
/>
{!internalValue?.trim() && placeholder && (
<pre
className={cn(
"pointer-events-none absolute top-4 left-7 text-muted-foreground text-sm",
{
"left-16": showLineNumbers,
}
)}
style={{ userSelect: "none" }}
>
{placeholder}
</pre>
)}
</div>
{render && isPreviewVisible && (
<div className="w-1/2 flex-1 overflow-auto border-l p-4">
{render(internalValue)}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import {
Empty as EmptyContainer,
EmptyDescription,
EmptyHeader,
EmptyMedia,
} from "@workspace/ui/components/empty";
import { cn } from "@workspace/ui/lib/utils";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
export default function Empty({
description,
border,
}: {
description?: React.ReactNode;
border?: boolean;
}) {
const { t } = useTranslation("components");
const messages = useMemo(
() => [
t(
"empty.tips.0",
"Imagine this space filled with exciting content! For now, you'll have to use your imagination..."
),
t(
"empty.tips.1",
"This area mysteriously disappeared, but we're summoning it back!"
),
t(
"empty.tips.2",
"Oh no, nothing happened... Feel free to fill in the blank!"
),
t(
"empty.tips.3",
"It's like discovering an empty stage at a concert... Why not go up and perform?"
),
t(
"empty.tips.4",
"You've found a blank canvas! How about building a house?"
),
t(
"empty.tips.5",
"This area is currently empty, but creativity starts here!"
),
t(
"empty.tips.6",
"Nothing here... but don't worry, it's just the beginning!"
),
t(
"empty.tips.7",
"This place was supposed to have a big surprise, but the surprise slipped away!"
),
t(
"empty.tips.8",
"There's nothing here for now, like an empty snack cabinet."
),
t(
"empty.tips.9",
"This empty space is waiting for its protagonist to take the stage!"
),
],
[t]
);
const [index, setIndex] = useState(0);
useEffect(() => {
setIndex(Math.floor(Math.random() * messages.length));
}, [messages]);
return (
<EmptyContainer className={cn(border ? "border" : "border-none")}>
<EmptyMedia>
<svg
className="text-background"
fill="currentColor"
height="41"
stroke="currentColor"
viewBox="0 0 64 41"
width="64"
xmlns="http://www.w3.org/2000/svg"
>
<title>Empty</title>
<g fill="none" fillRule="evenodd" transform="translate(0 1)">
<ellipse
cx="32"
cy="33"
fill="currentColor"
opacity={0.8}
rx="32"
ry="7"
/>
<g fillRule="nonzero" stroke="#d9d9d9">
<path d="M55 12.76L44.854 1.258C44.367.474 43.656 0 42.907 0H21.093c-.749 0-1.46.474-1.947 1.257L9 12.761V22h46v-9.24z" />
<path
d="M41.613 15.931c0-1.605.994-2.93 2.227-2.931H55v18.137C55 33.26 53.68 35 52.05 35h-40.1C10.32 35 9 33.259 9 31.137V13h11.16c1.233 0 2.227 1.323 2.227 2.928v.022c0 1.605 1.005 2.901 2.237 2.901h14.752c1.232 0 2.237-1.308 2.237-2.913v-.007z"
fill="currentColor"
/>
</g>
</g>
</svg>
</EmptyMedia>
<EmptyHeader>
<EmptyDescription>{description || messages[index]}</EmptyDescription>
</EmptyHeader>
</EmptyContainer>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { Input } from "@workspace/ui/components/input";
import { cn } from "@workspace/ui/lib/utils";
import { type ChangeEvent, type ReactNode, useEffect, useState } from "react";
export interface EnhancedInputProps<T = string>
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"prefix" | "value" | "onChange"
> {
prefix?: string | ReactNode;
suffix?: string | ReactNode;
value?: T;
formatInput?: (value: T) => string | number;
formatOutput?: (value: string | number) => T;
onValueChange?: (value: T) => void;
onValueBlur?: (value: T) => void;
min?: number;
max?: number;
}
export function EnhancedInput<T = string>({
suffix,
prefix,
formatInput,
formatOutput,
value: initialValue,
className,
onValueChange,
onValueBlur,
...props
}: EnhancedInputProps<T>) {
const getProcessedValue = (inputValue: unknown) => {
if (inputValue === "" || inputValue === 0 || inputValue === "0") return "";
const newValue = String(inputValue ?? "");
return formatInput ? formatInput(inputValue as T) : newValue;
};
const [value, setValue] = useState<string | number>(() =>
getProcessedValue(initialValue)
);
const [internalValue, setInternalValue] = useState<T | string | number>(
initialValue ?? ""
);
useEffect(() => {
if (initialValue !== internalValue) {
const newValue = getProcessedValue(initialValue);
if (value !== newValue) {
setValue(newValue);
setInternalValue(initialValue ?? "");
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValue, formatInput]);
const processValue = (inputValue: string | number): T => {
let processedValue: number | string = inputValue?.toString().trim();
if (processedValue === "0" && props.type === "number") {
return (formatOutput ? formatOutput(0) : 0) as T;
}
if (processedValue && props.type === "number")
processedValue = Number(processedValue);
return formatOutput ? formatOutput(processedValue) : (processedValue as T);
};
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
let inputValue = e.target.value;
if (props.type === "number") {
if (inputValue === "0") {
setValue("");
setInternalValue(0);
onValueChange?.(processValue(0));
return;
}
if (
/^-?\d*\.?\d*$/.test(inputValue) ||
inputValue === "-" ||
inputValue === "."
) {
const numericValue = Number(inputValue);
if (
!Number.isNaN(numericValue) &&
inputValue !== "-" &&
inputValue !== "."
) {
const min = Number.isFinite(props.min)
? props.min
: Number.NEGATIVE_INFINITY;
const max = Number.isFinite(props.max)
? props.max
: Number.POSITIVE_INFINITY;
const constrainedValue = Math.max(min!, Math.min(max!, numericValue));
inputValue = String(constrainedValue);
setInternalValue(constrainedValue);
} else {
setInternalValue(inputValue);
}
setValue(inputValue === "0" ? "" : inputValue);
}
} else {
setValue(inputValue);
setInternalValue(inputValue);
}
const outputValue = processValue(inputValue);
onValueChange?.(outputValue);
};
const handleBlur = () => {
if (props.type === "number" && value) {
if (value === "-" || value === ".") {
setValue("");
setInternalValue("");
onValueBlur?.("" as T);
return;
}
if (value === "0") {
setValue("");
onValueBlur?.(processValue(0));
return;
}
}
const outputValue = processValue(value);
if ((initialValue || "") !== outputValue) {
onValueBlur?.(outputValue);
}
};
const renderPrefix = () =>
typeof prefix === "string" ? (
<div className="relative mr-px flex h-9 items-center text-nowrap bg-muted px-3">
{prefix}
</div>
) : (
prefix
);
const renderSuffix = () =>
typeof suffix === "string" ? (
<div className="relative ml-px flex h-9 items-center text-nowrap bg-muted px-3">
{suffix}
</div>
) : (
suffix
);
return (
<div
className={cn(
"flex w-full items-center overflow-hidden rounded-md border border-input",
className
)}
suppressHydrationWarning
>
{renderPrefix()}
<Input
autoComplete="off"
step={0.01}
{...props}
className="block rounded-none border-none"
onBlur={handleBlur}
onChange={handleChange}
value={value}
/>
{renderSuffix()}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
// import { icons as FlagPack } from '@iconify-json/flagpack';
// import { icons as Logos } from '@iconify-json/logos';
// import { icons as Mdi } from '@iconify-json/mdi';
// import { icons as Simple } from '@iconify-json/simple-icons';
// import { icons as Uil } from '@iconify-json/uil';
import { Icon as Iconify, type IconProps } from "@iconify/react";
// addCollection(FlagPack);
// addCollection(Mdi);
// addCollection(Uil);
// addCollection(Simple);
// addCollection(Logos);
export function Icon(props: IconProps) {
return <Iconify {...props} />;
}
@@ -0,0 +1,58 @@
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { useLanguage } from "@workspace/ui/integrations/language";
import { Check, Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
const languages = [
{
code: "en-US",
name: "English",
flag: "🇺🇸",
},
{
code: "zh-CN",
name: "中文",
flag: "🇨🇳",
},
];
export function LanguageSwitch() {
const { language, changeLanguage } = useLanguage();
const { t } = useTranslation("components");
const currentLanguage = languages.find((lang) => lang.code === language);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="scale-95 rounded-full" size="icon" variant="ghost">
<Languages className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">{t("language", "Language")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{languages.map((language) => (
<DropdownMenuItem
className="flex items-center justify-between"
key={language.code}
onClick={() => changeLanguage(language.code as "en-US" | "zh-CN")}
>
<div className="flex items-center gap-2">
<span>{language.flag}</span>
<span>{language.name}</span>
</div>
{currentLanguage?.code === language.code && (
<Check className="h-4 w-4" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+89
View File
@@ -0,0 +1,89 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { cn } from "@workspace/ui/lib/utils";
import { useEffect, useRef, useState } from "react";
type LongTextProps = {
children: React.ReactNode;
className?: string;
contentClassName?: string;
};
export function LongText({
children,
className = "",
contentClassName = "",
}: LongTextProps) {
const ref = useRef<HTMLDivElement>(null);
const [isOverflown, setIsOverflown] = useState(false);
useEffect(() => {
if (checkOverflow(ref.current)) {
setIsOverflown(true);
return;
}
setIsOverflown(false);
}, []);
if (!isOverflown)
return (
<div className={cn("truncate", className)} ref={ref}>
{children}
</div>
);
return (
<>
<div className="hidden sm:block">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn("truncate", className)} ref={ref}>
{children}
</div>
</TooltipTrigger>
<TooltipContent>
<p className={cn("whitespace-pre-line", contentClassName)}>
{children}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="sm:hidden">
<Popover>
<PopoverTrigger asChild>
<div className={cn("truncate", className)} ref={ref}>
{children}
</div>
</PopoverTrigger>
<PopoverContent
className={cn("w-fit whitespace-pre-line", contentClassName)}
>
<p>{children}</p>
</PopoverContent>
</Popover>
</div>
</>
);
}
const checkOverflow = (textContainer: HTMLDivElement | null) => {
if (textContainer) {
return (
textContainer.offsetHeight < textContainer.scrollHeight ||
textContainer.offsetWidth < textContainer.scrollWidth
);
}
return false;
};
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import { cn } from "@workspace/ui/lib/utils";
import { Check, Copy } from "lucide-react";
import { useCallback, useState } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkToc from "remark-toc";
interface CodeBlockProps {
className?: string;
children?: React.ReactNode;
[key: string]: unknown;
}
function CodeBlock({ className, children, ...props }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const match = className?.startsWith("language-")
? /language-(\w+)/.exec(className)
: null;
const handleCopy = useCallback((text: string) => {
navigator.clipboard
.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 3000);
})
.catch(() => {
alert("Failed to copy text. Please try again.");
});
}, []);
if (match) {
return (
<div className="group relative my-4 w-full overflow-hidden rounded-lg">
<div className="flex items-center justify-between gap-4 bg-muted px-4 py-2 font-semibold text-sm">
<span className="lowercase [&>span]:text-xs">{match[1]}</span>
<Button
className="absolute top-0 right-2 z-20 p-0.5 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
onClick={() => handleCopy(String(children).replace(/\n$/, ""))}
size="icon"
variant="ghost"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</Button>
</div>
<SyntaxHighlighter
{...props}
customStyle={{
margin: 0,
borderRadius: 0,
}}
language={match[1]}
PreTag="div"
showLineNumbers
style={oneDark}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
</div>
);
}
return (
<code {...props} className={cn(className, "rounded border font-semibold")}>
{children}
</code>
);
}
interface MarkdownProps {
children: string;
components?: Components;
}
export function Markdown({ children, components }: MarkdownProps) {
return (
<div className="prose dark:prose-invert wrap-break-word w-full max-w-[unset]">
<ReactMarkdown
components={{
h1: ({ node, className, ...props }) => (
<h1
className={cn(
"mb-8 scroll-m-20 font-extrabold text-4xl tracking-tight last:mb-0",
className
)}
{...props}
/>
),
h2: ({ node, className, ...props }) => (
<h2
className={cn(
"mt-8 mb-4 scroll-m-20 font-semibold text-3xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
/>
),
h3: ({ node, className, ...props }) => (
<h3
className={cn(
"mt-6 mb-4 scroll-m-20 font-semibold text-2xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
/>
),
h4: ({ node, className, ...props }) => (
<h4
className={cn(
"mt-6 mb-4 scroll-m-20 font-semibold text-xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
/>
),
h5: ({ node, className, ...props }) => (
<h5
className={cn(
"my-4 font-semibold text-lg first:mt-0 last:mb-0",
className
)}
{...props}
/>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
h6: ({ node, className, ...props }) => (
<h6
className={cn(
"my-4 font-semibold first:mt-0 last:mb-0",
className
)}
{...props}
/>
),
p: ({ node, className, ...props }) => (
<p
className={cn(
"mt-5 mb-5 leading-7 first:mt-0 last:mb-0",
className
)}
{...props}
/>
),
a: ({ node, className, ...props }) => (
<a
className={cn(
"font-medium text-primary underline underline-offset-4",
className
)}
target="_blank"
{...props}
/>
),
blockquote: ({ node, className, ...props }) => (
<blockquote
className={cn("border-l-2 pl-6 italic", className)}
{...props}
/>
),
ul: ({ node, className, ...props }) => (
<ul
className={cn("my-5 ml-6 list-disc [&>li]:mt-2", className)}
{...props}
/>
),
ol: ({ node, className, ...props }) => (
<ol
className={cn("my-5 ml-6 list-decimal [&>li]:mt-2", className)}
{...props}
/>
),
hr: ({ node, className, ...props }) => (
<hr className={cn("my-5 border-b", className)} {...props} />
),
table: ({ node, className, ...props }) => (
<table
className={cn(
"my-5 w-full border-separate border-spacing-0 overflow-y-auto",
className
)}
{...props}
/>
),
th: ({ node, className, ...props }) => (
<th
className={cn(
"bg-muted px-4 py-2 text-left font-bold first:rounded-tl-lg last:rounded-tr-lg [&[align=center]]:text-center [&[align=right]]:text-right",
className
)}
{...props}
/>
),
td: ({ node, className, ...props }) => (
<td
className={cn(
"border-b border-l px-4 py-2 text-left last:border-r [&[align=center]]:text-center [&[align=right]]:text-right",
className
)}
{...props}
/>
),
tr: ({ node, className, ...props }) => (
<tr
className={cn(
"m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg",
className
)}
{...props}
/>
),
sup: ({ node, className, ...props }) => (
<sup
className={cn("[&>a]:text-xs [&>a]:no-underline", className)}
{...props}
/>
),
pre: ({ node, className, ...props }) => (
<pre
className={cn("overflow-x-auto rounded-b-lg p-0", className)}
{...props}
/>
),
code(props) {
return <CodeBlock {...(props as CodeBlockProps)} />;
},
...components,
}}
rehypePlugins={[rehypeRaw, rehypeKatex]}
remarkPlugins={[remarkGfm, remarkToc, remarkMath]}
>
{children}
</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,25 @@
import { useRouterState } from "@tanstack/react-router";
import { useEffect, useRef } from "react";
import LoadingBar, { type LoadingBarRef } from "react-top-loading-bar";
export function NavigationProgress() {
const ref = useRef<LoadingBarRef>(null);
const state = useRouterState();
useEffect(() => {
if (state.status === "pending") {
ref.current?.continuousStart();
} else {
ref.current?.complete();
}
}, [state.status]);
return (
<LoadingBar
color="var(--muted-foreground)"
height={2}
ref={ref}
shadow={true}
/>
);
}
@@ -0,0 +1,42 @@
import { Button } from "@workspace/ui/components/button";
import { Input } from "@workspace/ui/components/input";
import { cn } from "@workspace/ui/lib/utils";
import { Eye, EyeOff } from "lucide-react";
import * as React from "react";
type PasswordInputProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"type"
> & {
ref?: React.Ref<HTMLInputElement>;
};
export function PasswordInput({
className,
disabled,
ref,
...props
}: PasswordInputProps) {
const [showPassword, setShowPassword] = React.useState(false);
return (
<div className={cn("relative rounded-md", className)}>
<Input
disabled={disabled}
ref={ref}
type={showPassword ? "text" : "password"}
{...props}
/>
<Button
className="-translate-y-1/2 absolute end-1 top-1/2 h-6 w-6 rounded-md text-muted-foreground"
disabled={disabled}
onClick={() => setShowPassword((prev) => !prev)}
size="icon"
type="button"
variant="ghost"
>
{showPassword ? <Eye size={18} /> : <EyeOff size={18} />}
</Button>
</div>
);
}
@@ -0,0 +1,95 @@
"use client";
import type { Table } from "@tanstack/react-table";
import { Input } from "@workspace/ui/components/input";
import { Combobox } from "@workspace/ui/composed/combobox";
export interface IParams {
key: string;
placeholder?: string;
options?: { label: string; value: string }[];
type?: "text" | "select" | "date";
}
interface ColumnFilterProps<TData> {
table: Table<TData>;
params: IParams[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
filters?: any;
}
export function ColumnFilter<TData>({
table,
params,
filters,
}: ColumnFilterProps<TData>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateFilter = (key: string, value: any) => {
table.setColumnFilters((prev) => {
const newFilters = prev.filter((filter) => filter.id !== key);
if (value) {
newFilters.push({ id: key, value });
}
return newFilters;
});
};
const toDateInput = (d: Date) => {
const pad = (n: number) => String(n).padStart(2, "0");
const yyyy = d.getFullYear();
const MM = pad(d.getMonth() + 1);
const dd = pad(d.getDate());
return `${yyyy}-${MM}-${dd}`;
};
return (
<div className="flex gap-2">
{params.map((param) => {
if (param.options || param.type === "select") {
return (
<Combobox
className="w-32"
key={param.key}
onChange={(value) => {
updateFilter(param.key, value);
}}
options={param.options}
placeholder={param.placeholder || "Choose..."}
value={filters[param.key] || ""}
/>
);
}
if (param.type === "date") {
const raw = filters[param.key];
const inputValue =
typeof raw === "number"
? toDateInput(new Date(raw))
: typeof raw === "string"
? raw
: "";
return (
<Input
className="block w-32"
key={param.key}
onChange={(event) => {
const v = event.target.value;
updateFilter(param.key, v || "");
}}
placeholder={param.placeholder}
type="date"
value={inputValue}
/>
);
}
return (
<Input
className="w-32"
key={param.key}
onChange={(event) => updateFilter(param.key, event.target.value)}
placeholder={param.placeholder || "Search..."}
value={filters[param.key] || ""}
/>
);
})}
</div>
);
}
@@ -0,0 +1,5 @@
export {
ProList,
type ProListActions,
type ProListProps,
} from "@workspace/ui/composed/pro-list/pro-list";
@@ -0,0 +1,112 @@
import type { Table } from "@tanstack/react-table";
import { Button } from "@workspace/ui/components/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
interface PaginationProps<TData> {
table: Table<TData>;
}
export function Pagination<TData>({ table }: PaginationProps<TData>) {
const { t } = useTranslation("components");
return (
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex-1 whitespace-nowrap text-center text-muted-foreground sm:text-left">
{t("pagination.pageInfo", "Page {{page}} of {{total}}", {
page: table.getState().pagination.pageIndex + 1,
total: table.getPageCount(),
})}
</div>
<div className="flex flex-grow items-center justify-center gap-2 sm:justify-end">
<div className="flex items-center space-x-2">
<p className="font-medium">
{t("pagination.rowsPerPage", "Rows per page")}
</p>
<Select
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
value={`${table.getState().pagination.pageSize}`}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 50, 100, 200].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
className="hidden lg:flex"
disabled={!table.getCanPreviousPage()}
onClick={() => table.setPageIndex(0)}
size="icon"
variant="outline"
>
<span className="sr-only">Go to first page</span>
<ChevronsLeftIcon className="h-4 w-4" />
</Button>
<Button
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
size="icon"
variant="outline"
>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Select
onValueChange={(value) => table.setPageIndex(Number(value) - 1)}
value={`${table.getState().pagination.pageIndex + 1}`}
>
<SelectTrigger className="w-[70px]">
<SelectValue placeholder="Select page number" />
</SelectTrigger>
<SelectContent className="w-12">
{Array.from({ length: table.getPageCount() }, (_, i) => (
<SelectItem key={i} value={`${i + 1}`}>
{i + 1}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
size="icon"
variant="outline"
>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
className="hidden lg:flex"
disabled={!table.getCanNextPage()}
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
size="icon"
variant="outline"
>
<span className="sr-only">Go to last page</span>
<ChevronsRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,231 @@
"use client";
import {
type ColumnFiltersState,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@workspace/ui/components/alert";
import { Button } from "@workspace/ui/components/button";
import { Checkbox } from "@workspace/ui/components/checkbox";
import Empty from "@workspace/ui/composed/empty";
import {
ColumnFilter,
type IParams,
} from "@workspace/ui/composed/pro-list/column-filter";
import { Pagination } from "@workspace/ui/composed/pro-list/pagination";
import { cn } from "@workspace/ui/lib/utils";
import { ListRestart, Loader, RefreshCcw } from "lucide-react";
import type React from "react";
import { useEffect, useImperativeHandle, useState } from "react";
export interface ProListProps<TData, TValue> {
request: (
pagination: {
page: number;
size: number;
},
filter: TValue
) => Promise<{ list: TData[]; total: number }>;
params?: IParams[];
header?: {
title?: React.ReactNode;
toolbar?: React.ReactNode | React.ReactNode[];
};
batchRender?: (rows: TData[]) => React.ReactNode[];
renderItem: (item: TData, checkbox: React.ReactNode) => React.ReactNode;
action?: React.Ref<ProListActions | undefined>;
texts?: Partial<{
textRowsPerPage: string;
textPageOf: (current: number, total: number) => string;
selectedRowsText: (total: number) => string;
}>;
empty?: React.ReactNode;
}
export interface ProListActions {
refresh: () => void;
reset: () => void;
}
export function ProList<TData, TValue extends Record<string, unknown>>({
request,
params,
header,
batchRender,
renderItem,
action,
texts,
empty,
}: ProListProps<TData, TValue>) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState<{ [key: number]: boolean }>(
{}
);
const [data, setData] = useState<TData[]>([]);
const [rowCount, setRowCount] = useState<number>(0);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
});
const [loading, setLoading] = useState(false);
const table = useReactTable({
data,
columns: [],
onPaginationChange: setPagination,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
state: {
columnFilters,
rowSelection,
pagination,
},
manualPagination: true,
manualFiltering: true,
rowCount,
});
const fetchData = async () => {
setLoading(true);
try {
const response = await request(
{
page: pagination.pageIndex + 1,
size: pagination.pageSize,
},
Object.fromEntries(
columnFilters.map((item) => [item.id, item.value])
) as TValue
);
setData(response.list);
setRowCount(response.total);
} catch (error) {
console.log("Fetch data error:", error);
} finally {
setLoading(false);
}
};
const reset = async () => {
table.resetColumnFilters();
table.resetGlobalFilter(true);
table.resetColumnVisibility();
setRowSelection({});
table.resetPagination();
};
useImperativeHandle(action, () => ({
refresh: fetchData,
reset,
}));
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagination.pageIndex, pagination.pageSize, columnFilters]);
const handleSelectionChange = (index: number, isSelected: boolean) => {
setRowSelection((prevSelection) => ({
...prevSelection,
[index]: isSelected,
}));
};
const selectedRows = data.filter((_, index) => rowSelection[index]);
const selectedCount = selectedRows.length;
return (
<div className="flex max-w-full flex-col gap-4 overflow-hidden">
<div className="flex flex-wrap-reverse items-center justify-between gap-4">
<div>
{params ? (
<ColumnFilter
filters={Object.fromEntries(
columnFilters.map((item) => [item.id, item.value])
)}
params={params}
table={table}
/>
) : (
header?.title
)}
</div>
<div className="flex flex-1 items-center justify-end gap-2">
{params && params?.length > 0 && (
<>
<Button
className="h-8 w-8 p-2"
onClick={fetchData}
variant="outline"
>
<RefreshCcw className="h-4 w-4" />
</Button>
<Button className="h-8 w-8 p-2" onClick={reset} variant="outline">
<ListRestart className="h-4 w-4" />
</Button>
</>
)}
{header?.toolbar}
</div>
</div>
{selectedCount > 0 && batchRender && (
<Alert className="flex items-center justify-between">
<AlertTitle className="m-0">
{texts?.selectedRowsText?.(selectedCount) ||
`Selected ${selectedCount} rows`}
</AlertTitle>
<AlertDescription className="flex gap-2">
{batchRender(selectedRows)}
</AlertDescription>
</Alert>
)}
<div
className={cn("relative overflow-x-auto", {
"rounded-xl border": data.length === 0,
})}
>
<div className="grid grid-cols-1 gap-4">
{data.length ? (
data.map((item, index) => {
const isSelected = !!rowSelection[index];
const checkbox = (
<Checkbox
aria-label="Select row"
checked={isSelected}
onCheckedChange={(value) =>
handleSelectionChange(index, !!value)
}
/>
);
return <div key={index}>{renderItem(item, checkbox)}</div>;
})
) : (
<div className="flex items-center justify-center py-24">
{empty || <Empty />}
</div>
)}
</div>
{loading && (
<div className="absolute top-0 z-20 flex h-full w-full items-center justify-center bg-muted/80">
<Loader className="h-4 w-4 animate-spin" />
</div>
)}
</div>
{rowCount > 0 && <Pagination table={table} />}
</div>
);
}
@@ -0,0 +1,95 @@
"use client";
import type { Table } from "@tanstack/react-table";
import { Input } from "@workspace/ui/components/input";
import { Combobox } from "@workspace/ui/composed/combobox";
export interface IParams {
key: string;
placeholder?: string;
options?: { label: string; value: string }[];
type?: "text" | "select" | "date";
}
interface ColumnFilterProps<TData> {
table: Table<TData>;
params: IParams[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
filters?: any;
}
export function ColumnFilter<TData>({
table,
params,
filters,
}: ColumnFilterProps<TData>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateFilter = (key: string, value: any) => {
table.setColumnFilters((prev) => {
const newFilters = prev.filter((filter) => filter.id !== key);
if (value) {
newFilters.push({ id: key, value });
}
return newFilters;
});
};
const toDateInput = (d: Date) => {
const pad = (n: number) => String(n).padStart(2, "0");
const yyyy = d.getFullYear();
const MM = pad(d.getMonth() + 1);
const dd = pad(d.getDate());
return `${yyyy}-${MM}-${dd}`;
};
return (
<div className="flex gap-2">
{params.map((param) => {
if (param.options || param.type === "select") {
return (
<Combobox
className="min-w-32 max-w-48"
key={param.key}
onChange={(value) => {
updateFilter(param.key, value);
}}
options={param.options}
placeholder={param.placeholder || "Choose..."}
value={filters[param.key] || ""}
/>
);
}
if (param.type === "date") {
const raw = filters[param.key];
const inputValue =
typeof raw === "number"
? toDateInput(new Date(raw))
: typeof raw === "string"
? raw
: "";
return (
<Input
className="block min-w-32"
key={param.key}
onChange={(event) => {
const v = event.target.value;
updateFilter(param.key, v || "");
}}
placeholder={param.placeholder}
type="date"
value={inputValue}
/>
);
}
return (
<Input
className="min-w-32"
key={param.key}
onChange={(event) => updateFilter(param.key, event.target.value)}
placeholder={param.placeholder || "Search..."}
value={filters[param.key] || ""}
/>
);
})}
</div>
);
}
@@ -0,0 +1,81 @@
import { flexRender, type Header } from "@tanstack/react-table";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { cn } from "@workspace/ui/lib/utils";
import {
ArrowDownIcon,
ArrowDownUpIcon,
ArrowUpIcon,
EyeOffIcon,
} from "lucide-react";
interface ColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
header: Header<TData, TValue>;
text?: Partial<{
asc: string;
desc: string;
hide: string;
}>;
}
export function ColumnHeader<TData, TValue>({
header,
className,
text,
}: ColumnHeaderProps<TData, TValue>) {
const column = header.column;
const title = header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext());
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
return (
<div className={cn("flex w-full items-center", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="!bg-transparent flex h-8 w-full justify-start p-0 text-sm"
variant="ghost"
>
<span>{title}</span>
{column.getIsSorted() === "desc" ? (
<ArrowDownIcon className="ml-2 h-4 w-4" />
) : column.getIsSorted() === "asc" ? (
<ArrowUpIcon className="ml-2 h-4 w-4" />
) : (
<ArrowDownUpIcon className="ml-2 h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
{text?.asc || "ASC"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
{text?.desc || "DESC"}
</DropdownMenuItem>
{column.getCanHide() && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeOffIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
{text?.hide || "Hide"}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
import type { Table } from "@tanstack/react-table";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { ListTodoIcon } from "lucide-react";
import type { ReactNode } from "react";
interface ColumnToggleProps<TData> {
table: Table<TData>;
}
export function ColumnToggle<TData>({ table }: ColumnToggleProps<TData>) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="outline">
<ListTodoIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[150px]">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" && column.getCanHide()
)
.map((column) => {
const columns = table
.getAllColumns()
.filter((item) => item.getIsVisible());
return (
<DropdownMenuCheckboxItem
checked={column.getIsVisible()}
className="capitalize"
disabled={
columns.length === 1 && columns?.[0]?.id === column.id
}
key={column.id}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.columnDef.header as ReactNode}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,5 @@
export {
ProTable,
type ProTableActions,
type ProTableProps,
} from "@workspace/ui/composed/pro-table/pro-table";
@@ -0,0 +1,112 @@
import type { Table } from "@tanstack/react-table";
import { Button } from "@workspace/ui/components/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
interface PaginationProps<TData> {
table: Table<TData>;
}
export function Pagination<TData>({ table }: PaginationProps<TData>) {
const { t } = useTranslation("components");
return (
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex-1 whitespace-nowrap text-center text-muted-foreground sm:text-left">
{t("pagination.pageInfo", "Page {{page}} of {{total}}", {
page: table.getState().pagination.pageIndex + 1,
total: table.getPageCount(),
})}
</div>
<div className="flex flex-grow items-center justify-center gap-2 sm:justify-end">
<div className="flex items-center space-x-2">
<p className="font-medium">
{t("pagination.rowsPerPage", "Rows per page")}
</p>
<Select
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
value={`${table.getState().pagination.pageSize}`}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 50, 100, 200].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
className="hidden lg:flex"
disabled={!table.getCanPreviousPage()}
onClick={() => table.setPageIndex(0)}
size="icon"
variant="outline"
>
<span className="sr-only">Go to first page</span>
<ChevronsLeftIcon className="h-4 w-4" />
</Button>
<Button
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
size="icon"
variant="outline"
>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Select
onValueChange={(value) => table.setPageIndex(Number(value) - 1)}
value={`${table.getState().pagination.pageIndex + 1}`}
>
<SelectTrigger className="w-[70px]">
<SelectValue placeholder="Select page number" />
</SelectTrigger>
<SelectContent className="w-12">
{Array.from({ length: table.getPageCount() }, (_, i) => (
<SelectItem key={i} value={`${i + 1}`}>
{i + 1}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
size="icon"
variant="outline"
>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
className="hidden lg:flex"
disabled={!table.getCanNextPage()}
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
size="icon"
variant="outline"
>
<span className="sr-only">Go to last page</span>
<ChevronsRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,437 @@
"use client";
import {
type ColumnDef,
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@workspace/ui/components/alert";
import { Button } from "@workspace/ui/components/button";
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
import Empty from "@workspace/ui/composed/empty";
import {
ColumnFilter,
type IParams,
} from "@workspace/ui/composed/pro-table/column-filter";
import { ColumnHeader } from "@workspace/ui/composed/pro-table/column-header";
import { ColumnToggle } from "@workspace/ui/composed/pro-table/column-toggle";
import { Pagination } from "@workspace/ui/composed/pro-table/pagination";
import { SortableRow } from "@workspace/ui/composed/pro-table/sortable-row";
import { ProTableWrapper } from "@workspace/ui/composed/pro-table/wrapper";
import { cn } from "@workspace/ui/lib/utils";
import { useSize } from "ahooks";
import { GripVertical, ListRestart, Loader, RefreshCcw } from "lucide-react";
import type React from "react";
import {
Fragment,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
export interface ProTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
request: (
pagination: {
page: number;
size: number;
},
filter: TValue
) => Promise<{ list: TData[]; total: number }>;
params?: IParams[];
header?: {
title?: React.ReactNode;
toolbar?: React.ReactNode | React.ReactNode[];
hidden?: boolean;
};
actions?: {
render?: (row: TData) => React.ReactNode[];
batchRender?: (rows: TData[]) => React.ReactNode[];
};
action?: React.Ref<ProTableActions | undefined>;
texts?: Partial<{
actions: string;
asc: string;
desc: string;
hide: string;
textRowsPerPage: string;
textPageOf: (current: number, total: number) => string;
selectedRowsText: (total: number) => string;
}>;
empty?: React.ReactNode;
onSort?: (
sourceId: string | number,
targetId: string | number | null,
items: TData[]
) => Promise<TData[]>;
initialFilters?: Record<string, unknown>;
}
export interface ProTableActions {
refresh: () => void;
reset: () => void;
}
export function ProTable<
TData extends Record<string, unknown> & { id?: string | number },
TValue extends Record<string, unknown>,
>({
columns,
request,
params,
header,
actions,
action,
texts,
empty,
onSort,
initialFilters,
}: ProTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(() => {
if (initialFilters) {
return Object.entries(initialFilters).map(([id, value]) => ({
id,
value,
})) as ColumnFiltersState;
}
return [];
});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const [data, setData] = useState<TData[]>([]);
const [rowCount, setRowCount] = useState<number>(0);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
});
const loading = useRef(false);
const table = useReactTable({
data,
columns: [
...(onSort
? [
{
id: "sortable",
header: (
<GripVertical className="h-4 w-4 cursor-move text-gray-500 hover:text-gray-700" />
),
enableSorting: false,
enableHiding: false,
},
]
: []),
...(actions?.batchRender ? [createSelectColumn<TData, TValue>()] : []),
...columns.map(
(column) =>
({
enableSorting: false,
...column,
}) as ColumnDef<TData, TValue>
),
...(actions?.render
? ([
{
id: "actions",
header: texts?.actions,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-2">
{actions?.render?.(row.original).map((item, index) => (
<Fragment key={index}>{item}</Fragment>
))}
</div>
),
enableSorting: false,
enableHiding: false,
},
] as ColumnDef<TData, TValue>[])
: []),
] as ColumnDef<TData, TValue>[],
onPaginationChange: setPagination,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination,
},
manualPagination: true,
manualFiltering: true,
rowCount,
manualSorting: true,
});
const fetchData = async () => {
if (loading.current) return;
loading.current = true;
try {
const response = await request(
{
page: pagination.pageIndex + 1,
size: pagination.pageSize,
},
Object.fromEntries(
columnFilters.map((item) => [item.id, item.value])
) as TValue
);
setData(response.list);
setRowCount(response.total);
} catch (error) {
console.log("Fetch data error:", error);
} finally {
loading.current = false;
}
};
const reset = async () => {
table.resetSorting();
table.resetColumnFilters();
table.resetGlobalFilter(true);
table.resetColumnVisibility();
table.resetRowSelection();
table.resetPagination();
};
const ref = useRef<HTMLDivElement>(null);
const size = useSize(ref);
useImperativeHandle(action, () => ({
refresh: fetchData,
reset,
}));
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
pagination.pageIndex,
pagination.pageSize,
JSON.stringify(columnFilters),
]);
const selectedRows = table
.getSelectedRowModel()
.flatRows.map((row) => row.original);
const selectedCount = selectedRows.length;
return (
<div className="flex flex-col gap-4" ref={ref}>
{!header?.hidden && (
<div className="flex flex-wrap-reverse items-center justify-between gap-4">
<div>
{params ? (
<ColumnFilter
filters={Object.fromEntries(
columnFilters.map((item) => [item.id, item.value])
)}
params={params}
table={table}
/>
) : (
header?.title
)}
</div>
<div className="flex flex-1 items-center justify-end gap-2">
<Button onClick={fetchData} size="icon" variant="outline">
<RefreshCcw />
</Button>
<ColumnToggle table={table} />
<Button onClick={reset} size="icon" variant="outline">
<ListRestart />
</Button>
{header?.toolbar}
</div>
</div>
)}
{selectedCount > 0 && actions?.batchRender && (
<Alert className="flex items-center justify-between">
<AlertTitle className="m-0">
{texts?.selectedRowsText?.(selectedCount) ||
`Selected ${selectedCount} rows`}
</AlertTitle>
<AlertDescription className="flex gap-2">
{actions.batchRender(selectedRows)}
</AlertDescription>
</Alert>
)}
<div
className="relative w-auto overflow-x-auto rounded-md border"
style={{
width: size?.width,
}}
>
<ProTableWrapper data={data} onSort={onSort} setData={setData}>
<Table className="w-full">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
className={cn(
"!z-auto",
getTableHeaderClass(header.column.id)
)}
key={header.id}
>
<ColumnHeader
header={header}
text={{
asc: texts?.asc,
desc: texts?.desc,
hide: texts?.hide,
}}
/>
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel()?.rows?.length ? (
onSort ? (
table.getRowModel().rows.map((row) => (
<SortableRow
data-state={row.getIsSelected() && "selected"}
id={
row.original.id
? String(row.original.id)
: String(row.index)
}
isSortable
key={
row.original.id
? String(row.original.id)
: String(row.index)
}
>
{row
.getVisibleCells()
.filter((cell) => cell.column.id !== "sortable")
.map((cell) => (
<TableCell
className={getTableCellClass(cell.column.id)}
key={cell.id}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</SortableRow>
))
) : (
table.getRowModel().rows.map((row) => (
<TableRow
data-state={row.getIsSelected() && "selected"}
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<TableCell
className={getTableCellClass(cell.column.id)}
key={cell.id}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)
) : (
<TableRow>
<TableCell className="py-24" colSpan={columns.length + 2}>
{empty || <Empty />}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ProTableWrapper>
{loading.current && (
<div className="absolute top-0 z-20 flex h-full w-full items-center justify-center bg-muted/80">
<Loader className="h-4 w-4 animate-spin" />
</div>
)}
</div>
{rowCount > 0 && <Pagination table={table} />}
</div>
);
}
function createSelectColumn<TData, TValue>(): ColumnDef<TData, TValue> {
return {
id: "selected",
header: ({ table }) => (
<Checkbox
aria-label="Select all"
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
),
enableSorting: false,
enableHiding: false,
};
}
function getTableHeaderClass(columnId: string) {
if (["sortable", "selected"].includes(columnId)) {
return "sticky left-0 z-10 bg-background shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)] [&:has([role=checkbox])]:pr-2";
}
if (columnId === "actions") {
return "sticky right-0 z-10 text-right bg-background shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]";
}
return "truncate";
}
function getTableCellClass(columnId: string) {
if (["sortable", "selected"].includes(columnId)) {
return "sticky left-0 bg-background shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)]";
}
if (columnId === "actions") {
return "sticky right-0 bg-background shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]";
}
return "truncate";
}
@@ -0,0 +1,40 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { TableCell, TableRow } from "@workspace/ui/components/table";
import { GripVertical } from "lucide-react";
import type React from "react";
interface SortableRowProps {
id: string;
children: React.ReactNode;
isSortable: boolean;
}
export function SortableRow({ id, children, isSortable }: SortableRowProps) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({
id,
disabled: !isSortable,
});
const style = {
transform: CSS.Transform.toString({
x: 0,
y: transform?.y || 0,
scaleX: transform?.scaleX || 1,
scaleY: transform?.scaleY || 1,
}),
transition,
};
return (
<TableRow ref={setNodeRef} style={style}>
{isSortable ? (
<TableCell className="cursor-move" {...listeners} {...attributes}>
<GripVertical className="h-4 w-4 cursor-move text-gray-500 hover:text-gray-700" />
</TableCell>
) : null}
{children}
</TableRow>
);
}
@@ -0,0 +1,58 @@
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
export function ProTableWrapper<TData extends { id?: string | number }>({
children,
onSort,
data,
setData,
}: {
children: React.ReactNode;
onSort?: (
sourceId: string | number,
targetId: string | number | null,
items: TData[]
) => Promise<TData[]>;
data: TData[];
setData: React.Dispatch<React.SetStateAction<TData[]>>;
}) {
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (onSort) {
const updatedData = await onSort(active.id, over?.id || null, data);
setData(updatedData);
}
};
if (!onSort) return children;
return (
<DndContext
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<SortableContext
items={data.map((item) => String(item.id))}
strategy={verticalListSortingStrategy}
>
{children}
</SortableContext>
</DndContext>
);
}
@@ -0,0 +1,63 @@
import { FormControl } from "@workspace/ui/components/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { cn } from "@workspace/ui/lib/utils";
import { Loader } from "lucide-react";
import type { ReactNode } from "react";
type SelectDropdownProps = {
onValueChange?: (value: string) => void;
defaultValue: string | undefined;
placeholder?: string;
isPending?: boolean;
items: { label: string | ReactNode; value: string }[] | undefined;
disabled?: boolean;
className?: string;
isControlled?: boolean;
};
export function SelectDropdown({
defaultValue,
onValueChange,
isPending,
items,
placeholder,
disabled,
className = "",
isControlled = false,
}: SelectDropdownProps) {
const defaultState = isControlled
? { value: defaultValue, onValueChange }
: { defaultValue, onValueChange };
return (
<Select {...defaultState}>
<FormControl>
<SelectTrigger className={cn(className)} disabled={disabled}>
<SelectValue placeholder={placeholder ?? "Select"} />
</SelectTrigger>
</FormControl>
<SelectContent>
{isPending ? (
<SelectItem className="h-14" disabled value="loading">
<div className="flex items-center justify-center gap-2">
<Loader className="h-5 w-5 animate-spin" />
{" "}
Loading...
</div>
</SelectItem>
) : (
items?.map(({ label, value }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { Badge } from "@workspace/ui/components/badge";
import { Input } from "@workspace/ui/components/input";
import { cn } from "@workspace/ui/lib/utils";
import { X } from "lucide-react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
interface TagInputProps {
value?: string[];
onChange?: (tags: string[]) => void;
placeholder?: string;
separator?: string;
className?: string;
options?: string[];
}
export function TagInput({
value = [],
onChange,
placeholder,
separator = ",",
className,
options = [],
}: TagInputProps) {
const [inputValue, setInputValue] = useState("");
const [tags, setTags] = useState<string[]>(value);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setTags(value.map((tag) => tag.trim()).filter((tag) => tag));
}, [value]);
function normalizeInput(input: string) {
return input.replace(//g, ",");
}
function addTag(tagValue?: string) {
let tagsToAdd: string[] = [];
let shouldKeepOpen = false;
if (tagValue) {
if (!tags.includes(tagValue)) {
tagsToAdd = [tagValue];
shouldKeepOpen = true;
}
} else if (inputValue.trim()) {
const normalizedInput = normalizeInput(inputValue);
tagsToAdd = normalizedInput
.split(separator)
.map((tag) => tag.trim())
.filter((tag) => tag && !tags.includes(tag));
}
if (tagsToAdd.length > 0) {
const updatedTags = [...tags, ...tagsToAdd];
updateTags(updatedTags);
}
setInputValue("");
if (shouldKeepOpen && options.length > 0) {
setTimeout(() => {
setOpen(true);
}, 10);
} else {
setOpen(false);
}
}
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (
event.key === "Enter" ||
event.key === separator ||
event.key === ""
) {
event.preventDefault();
addTag();
} else if (event.key === "Backspace" && inputValue === "") {
event.preventDefault();
handleRemoveTag(tags.length - 1);
} else if (event.key === "Escape") {
setOpen(false);
}
}
function handleInputFocus() {
if (options.length > 0) {
setOpen(true);
}
}
function handleInputBlur() {
if (inputValue.trim()) addTag();
setOpen(false);
}
function handleRemoveTag(index: number) {
const newTags = tags.filter((_, i) => i !== index);
updateTags(newTags);
}
function updateTags(newTags: string[]) {
setTags(newTags);
onChange?.(newTags);
}
const availableOptions = options
.filter((option) => !tags.includes(option))
.filter(
(option) =>
inputValue.trim() === "" ||
option.toLowerCase().includes(inputValue.toLowerCase())
);
return (
<div className={cn("relative", className)}>
<div
className={cn(
"flex min-h-9 w-full cursor-text flex-wrap items-center gap-2 rounded-md border border-input bg-transparent p-2 shadow-sm transition-colors focus-within:ring-0 focus-within:ring-primary"
)}
onClick={() => inputRef.current?.focus()}
>
{tags.map((tag, index) => (
<Badge
className="flex items-center gap-1 border-primary bg-primary/10 px-1"
key={tag}
onClick={(e) => e.stopPropagation()}
variant="outline"
>
{tag}
<X
className="size-4 cursor-pointer rounded-sm hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleRemoveTag(index);
}}
/>
</Badge>
))}
<div className="flex min-w-0 flex-1 items-center gap-2">
<Input
className="!ring-0 h-full min-w-0 flex-1 border-none bg-transparent p-0 shadow-none"
onBlur={handleInputBlur}
onChange={(e) => setInputValue(e.target.value)}
onFocus={handleInputFocus}
onKeyDown={handleKeyDown}
placeholder={placeholder}
ref={inputRef}
value={inputValue}
/>
{open && availableOptions.length > 0 && (
<div className="absolute top-full left-0 z-50 max-h-60 w-full overflow-auto rounded-md border bg-popover text-popover-foreground shadow-md">
{availableOptions.map((option) => (
<div
className="relative flex cursor-pointer select-none items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
key={option}
onMouseDown={(e) => {
e.preventDefault();
addTag(option);
setTimeout(() => {
inputRef.current?.focus();
}, 10);
}}
>
{option}
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
export default TagInput;
+60
View File
@@ -0,0 +1,60 @@
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { useTheme } from "@workspace/ui/integrations/theme";
import { cn } from "@workspace/ui/lib/utils";
import { Check, Moon, Sun } from "lucide-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
export function ThemeSwitch() {
const { t } = useTranslation("components");
const { theme, setTheme } = useTheme();
/* Update theme-color meta tag
* when theme is updated */
useEffect(() => {
const themeColor = theme === "dark" ? "#020817" : "#fff";
const metaThemeColor = document.querySelector("meta[name='theme-color']");
if (metaThemeColor) metaThemeColor.setAttribute("content", themeColor);
}, [theme]);
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button className="scale-95 rounded-full" size="icon" variant="ghost">
<Sun className="dark:-rotate-90 size-[1.2rem] rotate-0 scale-100 transition-all dark:scale-0" />
<Moon className="absolute size-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">{t("theme.toggle", "Toggle theme")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
{t("theme.light", "Light")}{" "}
<Check
className={cn("ms-auto", theme !== "light" && "hidden")}
size={14}
/>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
{t("theme.dark", "Dark")}
<Check
className={cn("ms-auto", theme !== "dark" && "hidden")}
size={14}
/>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
{t("theme.system", "System")}
<Check
className={cn("ms-auto", theme !== "system" && "hidden")}
size={14}
/>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

Some files were not shown because too many files have changed in this diff Show More