feat: turbostarter boilerplate
Production-ready Next.js boilerplate with: - Runtime env validation (fail-fast on missing vars) - Feature-gated config (S3, Stripe, email, OAuth) - Docker + Coolify deployment pipeline - PostgreSQL + pgvector, MinIO S3, Better Auth - TypeScript strict mode (no ignoreBuildErrors) - i18n (en/es), AI modules, billing, monitoring Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
66
packages/ui/web/src/components/accordion.tsx
Normal file
66
packages/ui/web/src/components/accordion.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
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
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Icons.ChevronDown className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
157
packages/ui/web/src/components/alert-dialog.tsx
Normal file
157
packages/ui/web/src/components/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
|
||||
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
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...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" }), "mt-2 sm:mt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
};
|
||||
73
packages/ui/web/src/components/alert.tsx
Normal file
73
packages/ui/web/src/components/alert.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
|
||||
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 [&_[data-slot=alert-description]]:text-muted-foreground",
|
||||
destructive:
|
||||
"border-destructive/20 bg-destructive/5 text-destructive *:data-[slot=alert-description]:text-destructive/90",
|
||||
primary:
|
||||
"border-primary/20 bg-primary/5 text-primary [&_[data-slot=alert-description]]:text-primary/90",
|
||||
success:
|
||||
"border-success/20 bg-success/5 text-success *:data-[slot=alert-description]:text-success/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 text-left font-medium tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"col-start-2 grid justify-items-start gap-1 text-left text-sm [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
176
packages/ui/web/src/components/animated-beam.tsx
Normal file
176
packages/ui/web/src/components/animated-beam.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
interface AnimatedBeamProps {
|
||||
className?: string;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
fromRef: React.RefObject<HTMLElement | null>;
|
||||
toRef: React.RefObject<HTMLElement | null>;
|
||||
curvature?: number;
|
||||
reverse?: boolean;
|
||||
pathColor?: string;
|
||||
pathWidth?: number;
|
||||
pathOpacity?: number;
|
||||
gradientStartColor?: string;
|
||||
gradientStopColor?: string;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
startXOffset?: number;
|
||||
startYOffset?: number;
|
||||
endXOffset?: number;
|
||||
endYOffset?: number;
|
||||
}
|
||||
|
||||
function AnimatedBeam({
|
||||
className,
|
||||
containerRef,
|
||||
fromRef,
|
||||
toRef,
|
||||
curvature = 0,
|
||||
reverse = false,
|
||||
duration = Math.random() * 3 + 4,
|
||||
delay = 0,
|
||||
pathColor = "gray",
|
||||
pathWidth = 2,
|
||||
pathOpacity = 0.2,
|
||||
gradientStartColor = "#ffaa40",
|
||||
gradientStopColor = "#9c40ff",
|
||||
startXOffset = 0,
|
||||
startYOffset = 0,
|
||||
endXOffset = 0,
|
||||
endYOffset = 0,
|
||||
}: AnimatedBeamProps) {
|
||||
const id = React.useId();
|
||||
const [pathD, setPathD] = React.useState("");
|
||||
const [svgDimensions, setSvgDimensions] = React.useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const gradientCoordinates = reverse
|
||||
? {
|
||||
x1: ["90%", "-10%"],
|
||||
x2: ["100%", "0%"],
|
||||
y1: ["0%", "0%"],
|
||||
y2: ["0%", "0%"],
|
||||
}
|
||||
: {
|
||||
x1: ["10%", "110%"],
|
||||
x2: ["0%", "100%"],
|
||||
y1: ["0%", "0%"],
|
||||
y2: ["0%", "0%"],
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const updatePath = () => {
|
||||
if (containerRef.current && fromRef.current && toRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const rectA = fromRef.current.getBoundingClientRect();
|
||||
const rectB = toRef.current.getBoundingClientRect();
|
||||
|
||||
const svgWidth = containerRect.width;
|
||||
const svgHeight = containerRect.height;
|
||||
setSvgDimensions({ width: svgWidth, height: svgHeight });
|
||||
|
||||
const startX =
|
||||
rectA.left - containerRect.left + rectA.width / 2 + startXOffset;
|
||||
const startY =
|
||||
rectA.top - containerRect.top + rectA.height / 2 + startYOffset;
|
||||
const endX =
|
||||
rectB.left - containerRect.left + rectB.width / 2 + endXOffset;
|
||||
const endY =
|
||||
rectB.top - containerRect.top + rectB.height / 2 + endYOffset;
|
||||
|
||||
const controlY = startY - curvature;
|
||||
const d = `M ${startX},${startY} Q ${(startX + endX) / 2},${controlY} ${endX},${endY}`;
|
||||
setPathD(d);
|
||||
}
|
||||
};
|
||||
|
||||
updatePath();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updatePath);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [
|
||||
containerRef,
|
||||
fromRef,
|
||||
toRef,
|
||||
curvature,
|
||||
startXOffset,
|
||||
startYOffset,
|
||||
endXOffset,
|
||||
endYOffset,
|
||||
]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
width={svgDimensions.width}
|
||||
height={svgDimensions.height}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(
|
||||
"pointer-events-none absolute left-0 top-0 transform-gpu stroke-2",
|
||||
className,
|
||||
)}
|
||||
viewBox={`0 0 ${svgDimensions.width} ${svgDimensions.height}`}
|
||||
>
|
||||
<path
|
||||
d={pathD}
|
||||
stroke={pathColor}
|
||||
strokeWidth={pathWidth}
|
||||
strokeOpacity={pathOpacity}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d={pathD}
|
||||
strokeWidth={pathWidth}
|
||||
stroke={`url(#${id})`}
|
||||
strokeOpacity="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<defs>
|
||||
<motion.linearGradient
|
||||
className="transform-gpu"
|
||||
id={id}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
initial={{
|
||||
x1: "0%",
|
||||
x2: "0%",
|
||||
y1: "0%",
|
||||
y2: "0%",
|
||||
}}
|
||||
animate={{
|
||||
x1: gradientCoordinates.x1,
|
||||
x2: gradientCoordinates.x2,
|
||||
y1: gradientCoordinates.y1,
|
||||
y2: gradientCoordinates.y2,
|
||||
}}
|
||||
transition={{
|
||||
delay,
|
||||
duration,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
repeat: Infinity,
|
||||
repeatDelay: 0,
|
||||
}}
|
||||
>
|
||||
<stop stopColor={gradientStartColor} stopOpacity="0" />
|
||||
<stop stopColor={gradientStartColor} />
|
||||
<stop offset="32.5%" stopColor={gradientStopColor} />
|
||||
<stop offset="100%" stopColor={gradientStopColor} stopOpacity="0" />
|
||||
</motion.linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export { AnimatedBeam };
|
||||
53
packages/ui/web/src/components/avatar.tsx
Normal file
53
packages/ui/web/src/components/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
48
packages/ui/web/src/components/badge.tsx
Normal file
48
packages/ui/web/src/components/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||
destructive:
|
||||
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 border-transparent text-white",
|
||||
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
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
111
packages/ui/web/src/components/breadcrumb.tsx
Normal file
111
packages/ui/web/src/components/breadcrumb.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icons.ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<Icons.MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">{t("more")}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
59
packages/ui/web/src/components/built-with.tsx
Normal file
59
packages/ui/web/src/components/built-with.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { buttonVariants } from "#components/button";
|
||||
|
||||
export const BuiltWith = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a">) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<a
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: "outline",
|
||||
className: "cursor-pointer gap-1.5 font-sans",
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
href="https://www.turbostarter.dev"
|
||||
target="_blank"
|
||||
{...props}
|
||||
>
|
||||
{t("builtWith")}{" "}
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<svg
|
||||
viewBox="0 0 512 517"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-primary h-5"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M383.309 10.9714C383.309 4.91208 388.221 0 394.28 0C400.339 0 405.251 4.91208 405.251 10.9714V122.57C405.251 138.493 398.332 153.631 386.292 164.051L367.707 180.134L391.678 204.105L428.239 190.516C441.132 185.724 449.686 173.419 449.686 159.664V58.5143C449.686 52.4549 454.598 47.5429 460.657 47.5429C466.717 47.5429 471.629 52.4549 471.629 58.5143V159.664C471.629 182.589 457.372 203.097 435.883 211.084L405.175 222.498C415.977 243.457 415.977 268.543 405.175 289.502L440.649 302.687C459.273 309.609 471.629 327.383 471.629 347.251V453.486C471.629 459.545 466.717 464.457 460.657 464.457C454.598 464.457 449.686 459.545 449.686 453.486V347.251C449.686 336.553 443.033 326.983 433.005 323.255L391.678 307.895L367.707 331.866L388.819 350.137C399.255 359.167 405.251 372.287 405.251 386.087V501.029C405.251 507.088 400.339 512 394.28 512C388.221 512 383.309 507.088 383.309 501.029V386.087C383.309 378.656 380.08 371.592 374.461 366.729L352.151 347.423L266.345 433.229C260.632 438.941 251.37 438.941 245.657 433.229L160.142 347.713L138.168 366.729C132.549 371.592 129.32 378.656 129.32 386.087V501.029C129.32 507.088 124.408 512 118.349 512C112.289 512 107.377 507.088 107.377 501.029V386.087C107.377 372.287 113.374 359.167 123.809 350.137L144.586 332.157L120.493 308.065L79.624 323.255C69.5957 326.983 62.9429 336.553 62.9429 347.251V453.486C62.9429 459.545 58.0308 464.457 51.9714 464.457C45.9121 464.457 41 459.545 41 453.486V347.251C41 327.383 53.3553 309.609 71.9792 302.687L106.928 289.698C95.991 268.634 95.991 243.366 106.928 222.303L76.7452 211.084C55.2562 203.097 41 182.589 41 159.664V58.5143C41 52.4549 45.9121 47.5429 51.9714 47.5429C58.0308 47.5429 62.9429 52.4549 62.9429 58.5143V159.664C62.9429 173.419 71.4966 185.724 84.39 190.516L120.494 203.935L144.586 179.843L126.337 164.051C114.296 153.631 107.377 138.493 107.377 122.57V10.9714C107.377 4.91208 112.289 0 118.349 0C124.408 0 129.32 4.91208 129.32 10.9714V122.57C129.32 132.124 133.472 141.206 140.696 147.458L160.142 164.287L245.657 78.7717C251.37 73.0588 260.632 73.0589 266.345 78.7717L352.151 164.577L371.933 147.458C379.157 141.206 383.309 132.124 383.309 122.57V10.9714ZM267.067 166.768C272.383 161.416 281.338 166.639 279.298 173.901L268.742 211.469C266.776 218.467 272.035 225.408 279.304 225.408H318.878C335.139 225.408 343.311 245.043 331.851 256.58L244.619 344.4C239.303 349.752 230.348 344.529 232.388 337.267L242.944 299.699C244.91 292.701 239.65 285.76 232.381 285.76H192.808C176.547 285.76 168.375 266.125 179.835 254.588L267.067 166.768Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 667 84"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-foreground h-[11px]"
|
||||
>
|
||||
<path
|
||||
d="M38.887 82H26.497V12.2781H0.140091V1.12708H65.2439V12.2781H38.887V82ZM106.939 57.4453V22.3027H118.315V82H107.727V72.5385C104.573 78.7335 97.5899 83.1264 89.4801 83.1264C77.428 83.1264 69.0929 76.0303 69.0929 60.7117V22.3027H80.4692V58.459C80.4692 68.8215 85.6505 73.1017 93.0845 73.1017C100.744 73.1017 106.939 66.7941 106.939 57.4453ZM144.364 51.4755V82H132.988V22.3027H143.575V34.918C147.743 26.6955 156.754 21.5143 166.553 21.5143V33.3411C153.713 32.6653 144.364 38.2971 144.364 51.4755ZM231.424 52.1514C231.424 70.0605 221.061 83.1264 204.954 83.1264C196.507 83.1264 189.298 78.8462 185.018 71.2995V82H174.43V1.12708H185.806V32.5526C189.974 25.2313 196.845 21.1764 204.954 21.1764C220.949 21.1764 231.424 34.0169 231.424 52.1514ZM219.597 52.1514C219.597 38.635 212.501 31.201 202.702 31.201C193.24 31.201 185.806 38.5224 185.806 51.9261C185.806 65.1045 193.015 72.9891 202.702 72.9891C212.501 72.9891 219.597 65.4425 219.597 52.1514ZM266.837 83.1264C249.941 83.1264 237.551 69.8353 237.551 52.1514C237.551 34.4675 249.941 21.1764 266.837 21.1764C283.732 21.1764 296.122 34.4675 296.122 52.1514C296.122 69.8353 283.732 83.1264 266.837 83.1264ZM266.837 73.1017C276.636 73.1017 284.408 65.2172 284.408 52.1514C284.408 39.0855 276.636 31.3136 266.837 31.3136C257.037 31.3136 249.378 39.0855 249.378 52.1514C249.378 65.2172 257.037 73.1017 266.837 73.1017ZM366.628 58.3464C366.628 72.4259 355.364 83.1264 335.766 83.1264C316.28 83.1264 304.453 72.4259 303.439 56.3189H316.054C316.617 66.5688 323.15 73.2144 335.54 73.2144C345.79 73.2144 353.45 68.371 353.45 60.0359C353.45 53.2777 349.057 49.8986 339.708 47.8712L327.994 45.6185C316.617 43.3657 306.255 37.6213 306.255 23.8796C306.255 10.2506 318.194 0.000719194 334.977 0.000719194C351.76 0.000719194 364.488 10.2506 365.502 26.3576H352.886C352.211 16.6709 345.227 9.91272 335.09 9.91272C324.615 9.91272 318.87 16.1077 318.87 23.0912C318.87 30.7505 325.516 33.4537 333.062 35.0306L345.002 37.396C358.856 40.2119 366.628 46.2943 366.628 58.3464ZM412.842 70.9616V80.9863C409.351 82.5632 406.309 83.1264 402.705 83.1264C391.667 83.1264 384.007 77.1566 384.007 63.9782V31.9894H370.829V22.3027H384.007V4.61881H395.384V22.3027H413.406V31.9894H395.384V61.3875C395.384 69.61 399.326 72.5385 405.408 72.5385C408.112 72.5385 410.477 72.088 412.842 70.9616ZM459.293 82V72.7638C455.576 79.4094 448.93 83.1264 440.144 83.1264C427.754 83.1264 419.645 76.0303 419.645 65.1045C419.645 53.3904 428.993 47.308 446.79 47.308C450.282 47.308 453.098 47.4206 457.941 47.9838V43.591C457.941 35.0306 453.323 30.1873 445.438 30.1873C437.103 30.1873 432.035 35.1433 431.697 43.4784H421.334C421.897 30.0746 431.471 21.1764 445.438 21.1764C460.194 21.1764 468.754 29.5115 468.754 43.7036V82H459.293ZM430.458 64.7666C430.458 70.9616 435.076 75.0165 442.397 75.0165C451.971 75.0165 457.941 69.0468 457.941 59.9233V55.0799C453.548 54.5167 450.394 54.4041 447.466 54.4041C436.089 54.4041 430.458 57.7832 430.458 64.7666ZM494.214 51.4755V82H482.838V22.3027H493.426V34.918C497.593 26.6955 506.604 21.5143 516.404 21.5143V33.3411C503.563 32.6653 494.214 38.2971 494.214 51.4755ZM561.588 70.9616V80.9863C558.097 82.5632 555.055 83.1264 551.451 83.1264C540.413 83.1264 532.753 77.1566 532.753 63.9782V31.9894H519.575V22.3027H532.753V4.61881H544.13V22.3027H562.152V31.9894H544.13V61.3875C544.13 69.61 548.072 72.5385 554.154 72.5385C556.858 72.5385 559.223 72.088 561.588 70.9616ZM594.787 83.1264C577.554 83.1264 565.952 70.6237 565.952 51.8135C565.952 34.1295 578.004 21.1764 594.449 21.1764C612.246 21.1764 624.073 35.5938 622.045 54.9673H577.554C578.455 67.132 584.537 74.2281 594.562 74.2281C603.01 74.2281 608.867 69.61 610.781 61.8381H622.045C619.117 75.1292 608.867 83.1264 594.787 83.1264ZM594.224 29.7367C585.1 29.7367 578.905 36.2696 577.666 47.4206H609.993C609.43 36.3823 603.46 29.7367 594.224 29.7367ZM644.39 51.4755V82H633.014V22.3027H643.602V34.918C647.769 26.6955 656.78 21.5143 666.58 21.5143V33.3411C653.739 32.6653 644.39 38.2971 644.39 51.4755Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
60
packages/ui/web/src/components/button.tsx
Normal file
60
packages/ui/web/src/components/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-']):not([class*='w-']):not([class*='h-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 dark:hover:bg-destructive/50 text-white",
|
||||
outline:
|
||||
"bg-background hover:bg-accent hover:text-accent-foreground dark:border-input dark:hover:bg-accent/50 border shadow-xs",
|
||||
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-10 px-4 py-2 has-[>svg]:px-3.5",
|
||||
sm: "h-9 gap-1.5 rounded-md px-3.5 has-[>svg]:px-3",
|
||||
lg: "h-11 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "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
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
215
packages/ui/web/src/components/calendar.tsx
Normal file
215
packages/ui/web/src/components/calendar.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import type { DayButton } from "react-day-picker";
|
||||
|
||||
import { Button, buttonVariants } from "#components/button";
|
||||
|
||||
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
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months,
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"has-focus:border-ring border-input has-focus:ring-ring/50 relative rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn(
|
||||
"bg-popover absolute inset-0 opacity-0",
|
||||
defaultClassNames.dropdown,
|
||||
),
|
||||
caption_label: cn(
|
||||
"font-medium select-none",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground flex-1 rounded-md text-[0.8rem] font-normal select-none",
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-(--cell-size) select-none",
|
||||
defaultClassNames.week_number_header,
|
||||
),
|
||||
week_number: cn(
|
||||
"text-muted-foreground text-[0.8rem] select-none",
|
||||
defaultClassNames.week_number,
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn(
|
||||
"bg-accent rounded-l-md",
|
||||
defaultClassNames.range_start,
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today,
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside,
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled,
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<Icons.ChevronLeft
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<Icons.ChevronRight
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Icons.ChevronDown className={cn("size-4", className)} {...props} />
|
||||
);
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...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
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
61
packages/ui/web/src/components/card.tsx
Normal file
61
packages/ui/web/src/components/card.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
export function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card text-card-foreground isolate overflow-hidden rounded-lg border shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("flex flex-col space-y-2 p-6", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
"text-2xl leading-none font-semibold tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return <div className={cn("p-6 pt-0 overflow-hidden rounded-[inherit]", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
}
|
||||
356
packages/ui/web/src/components/chart.tsx
Normal file
356
packages/ui/web/src/components/chart.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>;
|
||||
|
||||
interface 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
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.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-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 flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<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]?.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(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const indicatorColor = color ?? item.payload.fill ?? item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item.value !== undefined && item.name ? (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
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="text-foreground font-mono font-medium 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.map((item) => {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
|
||||
)}
|
||||
>
|
||||
{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 undefined;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
32
packages/ui/web/src/components/checkbox.tsx
Normal file
32
packages/ui/web/src/components/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<Icons.Check className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
185
packages/ui/web/src/components/command.tsx
Normal file
185
packages/ui/web/src/components/command.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "#components/dialog";
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className,
|
||||
)}
|
||||
{...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="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[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
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-10 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<Icons.Search className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { Column } from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "#components/dropdown-menu";
|
||||
import { Icons } from "#components/icons";
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.ComponentProps<typeof DropdownMenuTrigger> {
|
||||
column: Column<TData, TValue>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
...props
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
if (!column.getCanSort() && !column.getCanHide()) {
|
||||
return <div className={cn(className)}>{title}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"hover:bg-accent focus:ring-ring data-[state=open]:bg-accent [&_svg]:text-muted-foreground -ml-1.5 flex h-8 items-center gap-1.5 rounded-md px-2 py-1.5 focus:ring-1 focus:outline-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{title}
|
||||
{column.getCanSort() &&
|
||||
(column.getIsSorted() === "desc" ? (
|
||||
<Icons.ChevronDown />
|
||||
) : column.getIsSorted() === "asc" ? (
|
||||
<Icons.ChevronUp />
|
||||
) : (
|
||||
<Icons.ChevronsUpDown />
|
||||
))}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-28 font-normal">
|
||||
{column.getCanSort() && (
|
||||
<>
|
||||
<DropdownMenuCheckboxItem
|
||||
className="[&_svg]:text-muted-foreground relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto"
|
||||
checked={column.getIsSorted() === "asc"}
|
||||
onClick={() => column.toggleSorting(false)}
|
||||
>
|
||||
<Icons.ChevronUp />
|
||||
{t("asc")}
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
className="[&_svg]:text-muted-foreground relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto"
|
||||
checked={column.getIsSorted() === "desc"}
|
||||
onClick={() => column.toggleSorting(true)}
|
||||
>
|
||||
<Icons.ChevronDown />
|
||||
{t("desc")}
|
||||
</DropdownMenuCheckboxItem>
|
||||
{column.getIsSorted() && (
|
||||
<DropdownMenuItem
|
||||
className="[&_svg]:text-muted-foreground pl-2"
|
||||
onClick={() => column.clearSorting()}
|
||||
>
|
||||
<Icons.X />
|
||||
{t("reset")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{column.getCanHide() && (
|
||||
<DropdownMenuCheckboxItem
|
||||
className="[&_svg]:text-muted-foreground relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto"
|
||||
checked={!column.getIsVisible()}
|
||||
onClick={() => column.toggleVisibility(false)}
|
||||
>
|
||||
<Icons.EyeOff />
|
||||
{t("hide")}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
import type { Column } from "@tanstack/react-table";
|
||||
import type { DateRange } from "react-day-picker";
|
||||
|
||||
import { Button } from "#components/button";
|
||||
import { Calendar } from "#components/calendar";
|
||||
import { Icons } from "#components/icons";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "#components/popover";
|
||||
import { Separator } from "#components/separator";
|
||||
|
||||
type DateSelection = Date[] | DateRange;
|
||||
|
||||
function getIsDateRange(value: DateSelection): value is DateRange {
|
||||
return typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseAsDate(timestamp: number | string | undefined): Date | undefined {
|
||||
if (!timestamp) return undefined;
|
||||
const numericTimestamp =
|
||||
typeof timestamp === "string" ? Number(timestamp) : timestamp;
|
||||
const date = new Date(numericTimestamp);
|
||||
return !Number.isNaN(date.getTime()) ? date : undefined;
|
||||
}
|
||||
|
||||
function parseColumnFilterValue(value: unknown) {
|
||||
if (value === null || value === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => {
|
||||
if (typeof item === "number" || typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return [value];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
interface DataTableDateFilterProps<TData> {
|
||||
column: Column<TData, unknown>;
|
||||
title?: string;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
export function DataTableDateFilter<TData>({
|
||||
column,
|
||||
title,
|
||||
multiple,
|
||||
}: DataTableDateFilterProps<TData>) {
|
||||
const { i18n, t } = useTranslation("common");
|
||||
const columnFilterValue = column.getFilterValue();
|
||||
|
||||
const selectedDates = React.useMemo<DateSelection>(() => {
|
||||
if (!columnFilterValue) {
|
||||
return multiple ? { from: undefined, to: undefined } : [];
|
||||
}
|
||||
|
||||
if (multiple) {
|
||||
const timestamps = parseColumnFilterValue(columnFilterValue);
|
||||
return {
|
||||
from: parseAsDate(timestamps[0]),
|
||||
to: parseAsDate(timestamps[1]),
|
||||
};
|
||||
}
|
||||
|
||||
const timestamps = parseColumnFilterValue(columnFilterValue);
|
||||
const date = parseAsDate(timestamps[0]);
|
||||
return date ? [date] : [];
|
||||
}, [columnFilterValue, multiple]);
|
||||
|
||||
const onSelect = React.useCallback(
|
||||
(date: Date | DateRange | undefined) => {
|
||||
if (!date) {
|
||||
column.setFilterValue(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (multiple && !("getTime" in date)) {
|
||||
const from = date.from?.getTime();
|
||||
const to = date.to?.getTime();
|
||||
column.setFilterValue(from || to ? [from, to] : undefined);
|
||||
} else if (!multiple && "getTime" in date) {
|
||||
column.setFilterValue(date.getTime());
|
||||
}
|
||||
},
|
||||
[column, multiple],
|
||||
);
|
||||
|
||||
const onReset = React.useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
column.setFilterValue(undefined);
|
||||
},
|
||||
[column],
|
||||
);
|
||||
|
||||
const hasValue = React.useMemo(() => {
|
||||
if (multiple) {
|
||||
if (!getIsDateRange(selectedDates)) return false;
|
||||
return selectedDates.from ?? selectedDates.to;
|
||||
}
|
||||
if (!Array.isArray(selectedDates)) return false;
|
||||
return selectedDates.length > 0;
|
||||
}, [multiple, selectedDates]);
|
||||
|
||||
const formatDateRange = React.useCallback(
|
||||
(range: DateRange) => {
|
||||
if (!range.from && !range.to) return "";
|
||||
if (range.from && range.to) {
|
||||
return `${range.from.toLocaleDateString(i18n.language)} - ${range.to.toLocaleDateString(i18n.language)}`;
|
||||
}
|
||||
return (range.from ?? range.to)?.toLocaleDateString(i18n.language);
|
||||
},
|
||||
[i18n.language],
|
||||
);
|
||||
|
||||
const label = React.useMemo(() => {
|
||||
if (multiple) {
|
||||
if (!getIsDateRange(selectedDates)) return null;
|
||||
|
||||
const hasSelectedDates = selectedDates.from ?? selectedDates.to;
|
||||
const dateText = hasSelectedDates
|
||||
? formatDateRange(selectedDates)
|
||||
: t("selectDateRange");
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{title}</span>
|
||||
{hasSelectedDates && (
|
||||
<>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-0.5 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<span>{dateText}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (getIsDateRange(selectedDates)) return null;
|
||||
|
||||
const hasSelectedDate = selectedDates.length > 0;
|
||||
const dateText = hasSelectedDate
|
||||
? selectedDates[0]?.toLocaleDateString(i18n.language)
|
||||
: t("selectDate");
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{title}</span>
|
||||
{hasSelectedDate && (
|
||||
<>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-0.5 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<span>{dateText}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}, [selectedDates, multiple, formatDateRange, title, t, i18n.language]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="border-dashed">
|
||||
{hasValue ? (
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`Clear ${title} filter`}
|
||||
tabIndex={0}
|
||||
onClick={onReset}
|
||||
className="focus-visible:ring-ring rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:outline-none"
|
||||
>
|
||||
<Icons.XCircle />
|
||||
</div>
|
||||
) : (
|
||||
<Icons.Calendar />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
{multiple ? (
|
||||
<Calendar
|
||||
captionLayout="dropdown"
|
||||
mode="range"
|
||||
selected={
|
||||
getIsDateRange(selectedDates)
|
||||
? selectedDates
|
||||
: { from: undefined, to: undefined }
|
||||
}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : (
|
||||
<Calendar
|
||||
captionLayout="dropdown"
|
||||
mode="single"
|
||||
selected={
|
||||
!getIsDateRange(selectedDates) ? selectedDates[0] : undefined
|
||||
}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { Icon } from "#components/icons";
|
||||
import type { Column } from "@tanstack/react-table";
|
||||
|
||||
import { Badge } from "#components/badge";
|
||||
import { Button } from "#components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "#components/command";
|
||||
import { Icons } from "#components/icons";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "#components/popover";
|
||||
import { Separator } from "#components/separator";
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: string;
|
||||
count?: number;
|
||||
icon?: Icon;
|
||||
}
|
||||
|
||||
interface DataTableFacetedFilterProps<TData, TValue> {
|
||||
column?: Column<TData, TValue>;
|
||||
title?: string;
|
||||
options: Option[];
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
export function DataTableFacetedFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
multiple,
|
||||
}: DataTableFacetedFilterProps<TData, TValue>) {
|
||||
const { t } = useTranslation("common");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const columnFilterValue = column?.getFilterValue();
|
||||
|
||||
const selectedValues = React.useMemo(
|
||||
() => new Set(Array.isArray(columnFilterValue) ? columnFilterValue : []),
|
||||
[columnFilterValue],
|
||||
);
|
||||
|
||||
const onItemSelect = React.useCallback(
|
||||
(option: Option, isSelected: boolean) => {
|
||||
if (!column) return;
|
||||
|
||||
if (multiple) {
|
||||
const newSelectedValues = new Set(selectedValues);
|
||||
if (isSelected) {
|
||||
newSelectedValues.delete(option.value);
|
||||
} else {
|
||||
newSelectedValues.add(option.value);
|
||||
}
|
||||
const filterValues = Array.from(newSelectedValues);
|
||||
column.setFilterValue(filterValues.length ? filterValues : undefined);
|
||||
} else {
|
||||
column.setFilterValue(isSelected ? undefined : [option.value]);
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[column, multiple, selectedValues],
|
||||
);
|
||||
|
||||
const onReset = React.useCallback(
|
||||
(event?: React.MouseEvent) => {
|
||||
event?.stopPropagation();
|
||||
column?.setFilterValue(undefined);
|
||||
},
|
||||
[column],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="border-dashed">
|
||||
{selectedValues.size > 0 ? (
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`Clear ${title} filter`}
|
||||
tabIndex={0}
|
||||
onClick={onReset}
|
||||
className="focus-visible:ring-ring rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:outline-none"
|
||||
>
|
||||
<Icons.XCircle />
|
||||
</div>
|
||||
) : (
|
||||
<Icons.PlusCircle />
|
||||
)}
|
||||
{title}
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-0.5 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden items-center gap-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{selectedValues.size} {t("selected")}
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[12.5rem] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList className="max-h-full">
|
||||
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
||||
<CommandGroup className="max-h-[18.75rem] overflow-x-hidden overflow-y-auto">
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => onItemSelect(option, isSelected)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-primary flex size-4 items-center justify-center rounded-[4px] border",
|
||||
isSelected
|
||||
? "bg-primary"
|
||||
: "opacity-50 [&_svg]:invisible",
|
||||
)}
|
||||
>
|
||||
<Icons.Check className="text-primary-foreground size-4" />
|
||||
</div>
|
||||
{option.icon && <option.icon />}
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.count && (
|
||||
<span className="ml-auto font-mono text-xs">
|
||||
{option.count}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => onReset()}
|
||||
className="justify-center text-center"
|
||||
>
|
||||
{t("clear")}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
|
||||
import { Button } from "#components/button";
|
||||
import { Icons } from "#components/icons";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "#components/select";
|
||||
|
||||
interface DataTablePaginationProps<TData> extends React.ComponentProps<"div"> {
|
||||
table: Table<TData>;
|
||||
pageSizeOptions?: number[];
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
pageSizeOptions = [10, 20, 30, 40, 50],
|
||||
className,
|
||||
...props
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{table.options.enableRowSelection && (
|
||||
<div className="text-muted-foreground flex-1 text-sm whitespace-nowrap">
|
||||
{t("rowsSelected", {
|
||||
selected: table.getFilteredSelectedRowModel().rows.length,
|
||||
total: table.getFilteredRowModel().rows.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col-reverse items-center gap-4 sm:ml-auto sm:flex-row sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium whitespace-nowrap">
|
||||
{t("rowsPerPage")}
|
||||
</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[4.5rem] [&[data-size]]:h-8">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{pageSizeOptions.map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-center text-sm font-medium">
|
||||
{t("pageOf", {
|
||||
page: table.getState().pagination.pageIndex + 1,
|
||||
total: table.getPageCount() || 1,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
aria-label={t("first")}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="hidden size-8 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<Icons.ChevronsLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("previous")}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<Icons.ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("next")}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<Icons.ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("last")}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="hidden size-8 lg:flex"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<Icons.ChevronsRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { Skeleton } from "#components/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "#components/table";
|
||||
|
||||
interface DataTableSkeletonProps extends React.ComponentProps<"div"> {
|
||||
columnCount: number;
|
||||
rowCount?: number;
|
||||
filterCount?: number;
|
||||
cellWidths?: string[];
|
||||
withViewOptions?: boolean;
|
||||
withPagination?: boolean;
|
||||
shrinkZero?: boolean;
|
||||
}
|
||||
|
||||
export function DataTableSkeleton({
|
||||
columnCount,
|
||||
rowCount = 10,
|
||||
filterCount = 0,
|
||||
cellWidths = ["auto"],
|
||||
withViewOptions = true,
|
||||
withPagination = true,
|
||||
shrinkZero = false,
|
||||
className,
|
||||
...props
|
||||
}: DataTableSkeletonProps) {
|
||||
const cozyCellWidths = Array.from(
|
||||
{ length: columnCount },
|
||||
(_, index) => cellWidths[index % cellWidths.length] ?? "auto",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full flex-col gap-2.5 overflow-auto", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2 overflow-auto">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
{filterCount > 0
|
||||
? Array.from({ length: filterCount }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-28 border-dashed" />
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
{withViewOptions ? (
|
||||
<Skeleton className="ml-auto hidden h-8 w-28 lg:flex" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{Array.from({ length: 1 }).map((_, i) => (
|
||||
<TableRow key={i} className="hover:bg-transparent">
|
||||
{Array.from({ length: columnCount }).map((_, j) => (
|
||||
<TableHead
|
||||
key={j}
|
||||
style={{
|
||||
width: cozyCellWidths[j],
|
||||
minWidth: shrinkZero ? cozyCellWidths[j] : "auto",
|
||||
}}
|
||||
>
|
||||
<Skeleton className="my-1 h-7 w-full" />
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rowCount }).map((_, i) => (
|
||||
<TableRow key={i} className="hover:bg-transparent">
|
||||
{Array.from({ length: columnCount }).map((_, j) => (
|
||||
<TableCell
|
||||
key={j}
|
||||
style={{
|
||||
width: cozyCellWidths[j],
|
||||
minWidth: shrinkZero ? cozyCellWidths[j] : "auto",
|
||||
}}
|
||||
>
|
||||
<Skeleton className="my-1 h-7 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{withPagination ? (
|
||||
<div className="flex w-full items-center justify-between gap-4 overflow-auto p-1 sm:gap-8">
|
||||
<Skeleton className="h-8 w-40 shrink-0" />
|
||||
<div className="flex items-center gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-8 w-[4.5rem]" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center text-sm font-medium">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="hidden size-8 lg:block" />
|
||||
<Skeleton className="size-8" />
|
||||
<Skeleton className="size-8" />
|
||||
<Skeleton className="hidden size-8 lg:block" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { Column } from "@tanstack/react-table";
|
||||
|
||||
import { Button } from "#components/button";
|
||||
import { Icons } from "#components/icons";
|
||||
import { Input } from "#components/input";
|
||||
import { Label } from "#components/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "#components/popover";
|
||||
import { Separator } from "#components/separator";
|
||||
import { Slider } from "#components/slider";
|
||||
|
||||
interface Range {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
type RangeValue = [number, number];
|
||||
|
||||
function getIsValidRange(value: unknown): value is RangeValue {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length === 2 &&
|
||||
typeof value[0] === "number" &&
|
||||
typeof value[1] === "number"
|
||||
);
|
||||
}
|
||||
|
||||
function parseValuesAsNumbers(value: unknown): RangeValue | undefined {
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.length === 2 &&
|
||||
value.every(
|
||||
(v) =>
|
||||
(typeof v === "string" || typeof v === "number") && !Number.isNaN(v),
|
||||
)
|
||||
) {
|
||||
return [Number(value[0]), Number(value[1])];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface DataTableSliderFilterProps<TData> {
|
||||
column: Column<TData, unknown>;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function DataTableSliderFilter<TData>({
|
||||
column,
|
||||
title,
|
||||
}: DataTableSliderFilterProps<TData>) {
|
||||
const { t } = useTranslation("common");
|
||||
const id = React.useId();
|
||||
|
||||
const columnFilterValue = parseValuesAsNumbers(column.getFilterValue());
|
||||
|
||||
const defaultRange =
|
||||
column.columnDef.meta &&
|
||||
"range" in column.columnDef.meta &&
|
||||
typeof column.columnDef.meta.range === "object"
|
||||
? column.columnDef.meta.range
|
||||
: undefined;
|
||||
const unit =
|
||||
column.columnDef.meta &&
|
||||
"unit" in column.columnDef.meta &&
|
||||
typeof column.columnDef.meta.unit === "string"
|
||||
? column.columnDef.meta.unit
|
||||
: undefined;
|
||||
|
||||
const { min, max, step } = React.useMemo<Range & { step: number }>(() => {
|
||||
let minValue = 0;
|
||||
let maxValue = 100;
|
||||
|
||||
if (defaultRange && getIsValidRange(defaultRange)) {
|
||||
[minValue, maxValue] = defaultRange;
|
||||
} else {
|
||||
const values = column.getFacetedMinMaxValues();
|
||||
if (values && Array.isArray(values)) {
|
||||
const [facetMinValue, facetMaxValue] = values;
|
||||
if (
|
||||
typeof facetMinValue === "number" &&
|
||||
typeof facetMaxValue === "number"
|
||||
) {
|
||||
minValue = facetMinValue;
|
||||
maxValue = facetMaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rangeSize = maxValue - minValue;
|
||||
const step =
|
||||
rangeSize <= 20
|
||||
? 1
|
||||
: rangeSize <= 100
|
||||
? Math.ceil(rangeSize / 20)
|
||||
: Math.ceil(rangeSize / 50);
|
||||
|
||||
return { min: minValue, max: maxValue, step };
|
||||
}, [column, defaultRange]);
|
||||
|
||||
const range = React.useMemo((): RangeValue => {
|
||||
return columnFilterValue ?? [min, max];
|
||||
}, [columnFilterValue, min, max]);
|
||||
|
||||
const formatValue = React.useCallback((value: number) => {
|
||||
return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
}, []);
|
||||
|
||||
const onFromInputChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const numValue = Number(event.target.value);
|
||||
if (!Number.isNaN(numValue) && numValue >= min && numValue <= range[1]) {
|
||||
column.setFilterValue([numValue, range[1]]);
|
||||
}
|
||||
},
|
||||
[column, min, range],
|
||||
);
|
||||
|
||||
const onToInputChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const numValue = Number(event.target.value);
|
||||
if (!Number.isNaN(numValue) && numValue <= max && numValue >= range[0]) {
|
||||
column.setFilterValue([range[0], numValue]);
|
||||
}
|
||||
},
|
||||
[column, max, range],
|
||||
);
|
||||
|
||||
const onSliderValueChange = React.useCallback(
|
||||
(value: RangeValue) => {
|
||||
if (Array.isArray(value)) {
|
||||
column.setFilterValue(value);
|
||||
}
|
||||
},
|
||||
[column],
|
||||
);
|
||||
|
||||
const onReset = React.useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
if (event.target instanceof HTMLDivElement) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
column.setFilterValue(undefined);
|
||||
},
|
||||
[column],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="border-dashed">
|
||||
{columnFilterValue ? (
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`Clear ${title} filter`}
|
||||
tabIndex={0}
|
||||
className="focus-visible:ring-ring rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:outline-none"
|
||||
onClick={onReset}
|
||||
>
|
||||
<Icons.XCircle />
|
||||
</div>
|
||||
) : (
|
||||
<Icons.PlusCircle />
|
||||
)}
|
||||
<span>{title}</span>
|
||||
{columnFilterValue ? (
|
||||
<>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-0.5 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
{formatValue(columnFilterValue[0])} -{" "}
|
||||
{formatValue(columnFilterValue[1])}
|
||||
{unit ? ` ${unit}` : ""}
|
||||
</>
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="flex w-auto flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Label htmlFor={`${id}-from`} className="sr-only">
|
||||
{t("from")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`${id}-from`}
|
||||
type="number"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
placeholder={min.toString()}
|
||||
min={min}
|
||||
max={max}
|
||||
value={range[0].toString()}
|
||||
onChange={onFromInputChange}
|
||||
className={cn("h-8 w-24", unit && "pr-8")}
|
||||
/>
|
||||
{unit && (
|
||||
<span className="bg-accent text-muted-foreground absolute top-0 right-0 bottom-0 flex items-center rounded-r-md px-2 text-sm">
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Label htmlFor={`${id}-to`} className="sr-only">
|
||||
{t("to")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`${id}-to`}
|
||||
type="number"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
placeholder={max.toString()}
|
||||
min={min}
|
||||
max={max}
|
||||
value={range[1].toString()}
|
||||
onChange={onToInputChange}
|
||||
className={cn("h-8 w-24", unit && "pr-8")}
|
||||
/>
|
||||
{unit && (
|
||||
<span className="bg-accent text-muted-foreground absolute top-0 right-0 bottom-0 flex items-center rounded-r-md px-2 text-sm">
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Label htmlFor={`${id}-slider`} className="sr-only">
|
||||
{title} {t("slider")}
|
||||
</Label>
|
||||
<Slider
|
||||
id={`${id}-slider`}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={range}
|
||||
onValueChange={onSliderValueChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={`${t("clear")} ${title}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
224
packages/ui/web/src/components/data-table/data-table-toolbar.tsx
Normal file
224
packages/ui/web/src/components/data-table/data-table-toolbar.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { DataTableDateFilter } from "./data-table-date-filter";
|
||||
import { DataTableFacetedFilter } from "./data-table-faceted-filter";
|
||||
import { DataTableSliderFilter } from "./data-table-slider-filter";
|
||||
import { DataTableViewOptions } from "./data-table-view-options";
|
||||
|
||||
import type { Icon } from "#components/icons";
|
||||
import type { Column, Table } from "@tanstack/react-table";
|
||||
|
||||
import { Button } from "#components/button";
|
||||
import { Icons } from "#components/icons";
|
||||
import { Input } from "#components/input";
|
||||
|
||||
interface DataTableToolbarProps<TData> extends React.ComponentProps<"div"> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const { t } = useTranslation("common");
|
||||
const isFiltered = table.getState().columnFilters.length > 0;
|
||||
|
||||
const columns = React.useMemo(
|
||||
() => table.getAllColumns().filter((column) => column.getCanFilter()),
|
||||
[table],
|
||||
);
|
||||
|
||||
const onReset = React.useCallback(() => {
|
||||
table.resetColumnFilters();
|
||||
}, [table]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-orientation="horizontal"
|
||||
className={cn("flex w-full items-start justify-between gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
{columns.map((column) => (
|
||||
<DataTableToolbarFilter key={column.id} column={column} />
|
||||
))}
|
||||
{isFiltered && (
|
||||
<Button
|
||||
aria-label="Reset filters"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-dashed"
|
||||
onClick={onReset}
|
||||
>
|
||||
<Icons.X />
|
||||
{t("reset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{children}
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface DataTableToolbarFilterProps<TData> {
|
||||
column: Column<TData>;
|
||||
}
|
||||
|
||||
const optionSchema = z
|
||||
.object({
|
||||
label: z.string(),
|
||||
value: z.any(),
|
||||
count: z.number().optional(),
|
||||
icon: z.unknown().optional(),
|
||||
})
|
||||
.transform((opt) => {
|
||||
let parsedIcon: Icon | undefined;
|
||||
if (typeof opt.icon === "string" && opt.icon in Icons) {
|
||||
parsedIcon = Icons[opt.icon as keyof typeof Icons] as Icon;
|
||||
} else if (typeof opt.icon === "function") {
|
||||
parsedIcon = opt.icon as Icon;
|
||||
}
|
||||
return { ...opt, icon: parsedIcon };
|
||||
});
|
||||
|
||||
const metaSchema = z.object({
|
||||
variant: z
|
||||
.enum([
|
||||
"text",
|
||||
"number",
|
||||
"range",
|
||||
"date",
|
||||
"dateRange",
|
||||
"select",
|
||||
"multiSelect",
|
||||
])
|
||||
.optional(),
|
||||
label: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
range: z.tuple([z.number(), z.number()]).optional(),
|
||||
options: z.array(optionSchema).optional(),
|
||||
});
|
||||
|
||||
function parseMeta(meta: unknown): {
|
||||
variant?:
|
||||
| "text"
|
||||
| "number"
|
||||
| "range"
|
||||
| "date"
|
||||
| "dateRange"
|
||||
| "select"
|
||||
| "multiSelect";
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
unit?: string;
|
||||
options: { label: string; value: string; count?: number; icon?: Icon }[];
|
||||
} {
|
||||
const result = metaSchema.safeParse(meta ?? {});
|
||||
if (!result.success) {
|
||||
return { options: [] };
|
||||
}
|
||||
const { variant, label, placeholder, unit, options } = result.data;
|
||||
return {
|
||||
variant,
|
||||
label,
|
||||
placeholder,
|
||||
unit,
|
||||
options: options ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function DataTableToolbarFilter<TData>({
|
||||
column,
|
||||
}: DataTableToolbarFilterProps<TData>) {
|
||||
{
|
||||
const onFilterRender = React.useCallback(() => {
|
||||
const { variant, label, placeholder, unit, options } = parseMeta(
|
||||
column.columnDef.meta,
|
||||
);
|
||||
|
||||
if (!variant) return null;
|
||||
|
||||
switch (variant) {
|
||||
case "text":
|
||||
return (
|
||||
<Input
|
||||
placeholder={placeholder ?? label}
|
||||
value={(() => {
|
||||
const value: unknown = column.getFilterValue();
|
||||
return typeof value === "string" ? value : "";
|
||||
})()}
|
||||
onChange={(event) => column.setFilterValue(event.target.value)}
|
||||
className="h-9 w-52 lg:w-72"
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={placeholder ?? label}
|
||||
value={(() => {
|
||||
const value: unknown = column.getFilterValue();
|
||||
return typeof value === "number" || typeof value === "string"
|
||||
? String(value)
|
||||
: "";
|
||||
})()}
|
||||
onChange={(event) => column.setFilterValue(event.target.value)}
|
||||
className={cn("h-8 w-[120px]", unit ? "pr-8" : undefined)}
|
||||
/>
|
||||
{unit && (
|
||||
<span className="bg-accent text-muted-foreground absolute top-0 right-0 bottom-0 flex items-center rounded-r-md px-2 text-sm">
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "range":
|
||||
return (
|
||||
<DataTableSliderFilter column={column} title={label ?? column.id} />
|
||||
);
|
||||
|
||||
case "date":
|
||||
case "dateRange":
|
||||
return (
|
||||
<DataTableDateFilter
|
||||
column={column}
|
||||
title={label ?? column.id}
|
||||
multiple={variant === "dateRange"}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
case "multiSelect":
|
||||
return (
|
||||
<DataTableFacetedFilter
|
||||
column={column}
|
||||
title={label ?? column.id}
|
||||
options={options}
|
||||
multiple={variant === "multiSelect"}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [column]);
|
||||
|
||||
return onFilterRender();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
|
||||
import { Button } from "#components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "#components/command";
|
||||
import { Icons } from "#components/icons";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "#components/popover";
|
||||
|
||||
interface DataTableViewOptionsProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
export function DataTableViewOptions<TData>({
|
||||
table,
|
||||
}: DataTableViewOptionsProps<TData>) {
|
||||
const { t } = useTranslation("common");
|
||||
const columns = React.useMemo(
|
||||
() =>
|
||||
table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" && column.getCanHide(),
|
||||
),
|
||||
[table],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label="Toggle columns"
|
||||
role="combobox"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto hidden gap-1.5 lg:flex"
|
||||
>
|
||||
<Icons.Settings2 className="size-4" />
|
||||
{t("view")}
|
||||
<Icons.ChevronsUpDown className="ml-auto size-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={`${t("searchColumns")}...`} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{columns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.id}
|
||||
onSelect={() =>
|
||||
column.toggleVisibility(!column.getIsVisible())
|
||||
}
|
||||
>
|
||||
<span className="truncate">
|
||||
{column.columnDef.meta &&
|
||||
"label" in column.columnDef.meta &&
|
||||
typeof column.columnDef.meta.label === "string"
|
||||
? column.columnDef.meta.label
|
||||
: column.id}
|
||||
</span>
|
||||
<Icons.Check
|
||||
className={cn(
|
||||
"ml-auto size-4 shrink-0",
|
||||
column.getIsVisible() ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
88
packages/ui/web/src/components/data-table/data-table.tsx
Normal file
88
packages/ui/web/src/components/data-table/data-table.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { DataTablePagination } from "./data-table-pagination";
|
||||
|
||||
import type { Table as TanstackTable } from "@tanstack/react-table";
|
||||
import type * as React from "react";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "#components/table";
|
||||
|
||||
interface DataTableProps<TData> extends React.ComponentProps<"div"> {
|
||||
table: TanstackTable<TData>;
|
||||
}
|
||||
|
||||
export function DataTable<TData>({
|
||||
table,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: DataTableProps<TData>) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full flex-col gap-2.5 overflow-auto", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={table.getAllColumns().length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("noResults")}.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
packages/ui/web/src/components/dialog.tsx
Normal file
148
packages/ui/web/src/components/dialog.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
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
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<Icons.X />
|
||||
<span className="sr-only">{t("close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-1 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"text-lg leading-none font-semibold tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
138
packages/ui/web/src/components/drawer.tsx
Normal file
138
packages/ui/web/src/components/drawer.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
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
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col items-start 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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn(
|
||||
"text-foreground text-xl leading-tight font-semibold tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
255
packages/ui/web/src/components/dropdown-menu.tsx
Normal file
255
packages/ui/web/src/components/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
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.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Icons.Check 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
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Icons.Circle 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
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...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
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Icons.ChevronRight className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
164
packages/ui/web/src/components/form.tsx
Normal file
164
packages/ui/web/src/components/form.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { isKey, useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { Label as LabelPrimitive } from "radix-ui";
|
||||
import type { ControllerProps, FieldPath, FieldValues } from "react-hook-form";
|
||||
|
||||
import { Label } from "#components/label";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
interface 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>) => {
|
||||
return (
|
||||
<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);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
interface 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
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error.message) : props.children;
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{typeof body === "string" && isKey(body, i18n) ? t(body) : body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
72
packages/ui/web/src/components/grid-pattern.tsx
Normal file
72
packages/ui/web/src/components/grid-pattern.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
interface GridPatternProps extends React.SVGProps<SVGSVGElement> {
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
squares?: number[][];
|
||||
strokeDasharray?: string;
|
||||
}
|
||||
|
||||
function GridPattern({
|
||||
width = 40,
|
||||
height = 40,
|
||||
x = -1,
|
||||
y = -1,
|
||||
strokeDasharray = "0",
|
||||
squares,
|
||||
className,
|
||||
...props
|
||||
}: GridPatternProps) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full fill-gray-400/30 stroke-gray-400/30",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={id}
|
||||
width={width}
|
||||
height={height}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={x}
|
||||
y={y}
|
||||
>
|
||||
<path
|
||||
d={`M.5 ${height}V.5H${width}`}
|
||||
fill="none"
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} />
|
||||
{squares && (
|
||||
<svg x={x} y={y} className="overflow-visible">
|
||||
{squares.map(([squareX, squareY]) => (
|
||||
<rect
|
||||
strokeWidth="0"
|
||||
key={`${squareX}-${squareY}`}
|
||||
width={width - 1}
|
||||
height={height - 1}
|
||||
x={squareX! * width + 1}
|
||||
y={squareY! * height + 1}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export { GridPattern };
|
||||
80
packages/ui/web/src/components/i18n.tsx
Normal file
80
packages/ui/web/src/components/i18n.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
config,
|
||||
Locale,
|
||||
LocaleLabel,
|
||||
useTranslation,
|
||||
} from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { Icon } from "#components/icons";
|
||||
|
||||
import { Icons } from "#components/icons";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "#components/select";
|
||||
|
||||
export const LocaleIcon: Record<Locale, Icon> = {
|
||||
[Locale.EN]: Icons.UnitedKingdom,
|
||||
[Locale.ES]: Icons.Spain,
|
||||
} as const;
|
||||
|
||||
interface LocaleCustomizerProps {
|
||||
readonly onChange?: (lang: Locale) => Promise<void>;
|
||||
readonly variant?: "default" | "icon";
|
||||
}
|
||||
|
||||
export const LocaleCustomizer = ({
|
||||
onChange,
|
||||
variant = "default",
|
||||
}: LocaleCustomizerProps) => {
|
||||
const { i18n, t } = useTranslation("common");
|
||||
const locale = i18n.language as Locale;
|
||||
|
||||
const handleLocaleChange = async (locale: Locale) => {
|
||||
await onChange?.(locale);
|
||||
await i18n.changeLanguage(locale);
|
||||
};
|
||||
|
||||
const Icon = LocaleIcon[locale];
|
||||
|
||||
return (
|
||||
<Select value={locale} onValueChange={handleLocaleChange}>
|
||||
<SelectTrigger
|
||||
className={cn({
|
||||
"w-full": variant === "default",
|
||||
"hover:bg-accent hover:text-accent-foreground flex size-10 items-center justify-center rounded-full border-none p-0 text-lg transition-colors [&>*:nth-child(2)]:hidden":
|
||||
variant === "icon",
|
||||
})}
|
||||
aria-label={t("language.change")}
|
||||
>
|
||||
{variant === "default" ? (
|
||||
<SelectValue aria-label={LocaleLabel[locale]} />
|
||||
) : (
|
||||
<SelectValue aria-label={LocaleLabel[locale]}>
|
||||
<Icon className="size-10" />
|
||||
</SelectValue>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{config.locales.map((lang) => {
|
||||
const Icon = LocaleIcon[lang];
|
||||
|
||||
return (
|
||||
<SelectItem key={lang} value={lang} className="cursor-pointer">
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="size-4" />
|
||||
{LocaleLabel[lang]}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
392
packages/ui/web/src/components/icons.tsx
Normal file
392
packages/ui/web/src/components/icons.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
AtSign,
|
||||
LogIn,
|
||||
UserRound,
|
||||
SunMoon,
|
||||
Moon,
|
||||
Sun,
|
||||
Undo2,
|
||||
Check,
|
||||
Loader,
|
||||
Loader2,
|
||||
CircleX,
|
||||
CheckCircle2,
|
||||
Gift,
|
||||
X,
|
||||
CheckIcon,
|
||||
ArrowUp,
|
||||
Globe2,
|
||||
GraduationCap,
|
||||
Atom,
|
||||
Brain,
|
||||
LogOut,
|
||||
Settings,
|
||||
Home,
|
||||
Star,
|
||||
CreditCard,
|
||||
EllipsisVertical,
|
||||
User2,
|
||||
LifeBuoy,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
MessageSquarePlus,
|
||||
Plus,
|
||||
Trash,
|
||||
Ellipsis,
|
||||
ClockFading,
|
||||
BadgeCheck,
|
||||
BadgeX,
|
||||
ArrowUpRight,
|
||||
Menu,
|
||||
MonitorSmartphone,
|
||||
Key,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
PaintBucket,
|
||||
ChartNoAxesGantt,
|
||||
BookOpen,
|
||||
Webhook,
|
||||
Download,
|
||||
ChevronRight,
|
||||
SendHorizontal,
|
||||
Copy,
|
||||
ChevronsUpDown,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
UserRoundPlus,
|
||||
UsersRound,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronsLeft,
|
||||
ChevronLeft,
|
||||
ChevronsRight,
|
||||
Settings2,
|
||||
ChevronDown,
|
||||
EyeOff,
|
||||
ChevronUp,
|
||||
XCircle,
|
||||
MailPlus,
|
||||
PlusCircle,
|
||||
Calendar,
|
||||
ShieldUser,
|
||||
Building,
|
||||
HandCoins,
|
||||
Code,
|
||||
VenetianMask,
|
||||
Ban,
|
||||
Eye,
|
||||
Lock,
|
||||
Slash,
|
||||
MoreHorizontal,
|
||||
MoreVertical,
|
||||
Circle,
|
||||
Minus,
|
||||
PanelLeft,
|
||||
Database,
|
||||
Snowflake,
|
||||
Cloud,
|
||||
Crown,
|
||||
Target,
|
||||
Megaphone,
|
||||
Package,
|
||||
Headphones,
|
||||
Info,
|
||||
Bell,
|
||||
Share2,
|
||||
LayoutDashboard,
|
||||
Pencil,
|
||||
BarChart3,
|
||||
// AI module icons
|
||||
ImagePlus,
|
||||
Image,
|
||||
Globe,
|
||||
Sparkle,
|
||||
Sparkles,
|
||||
Square,
|
||||
SquarePen,
|
||||
TextSearch,
|
||||
MessagesSquare,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
ChartNoAxesColumn,
|
||||
Zap,
|
||||
PackageOpen,
|
||||
Paperclip,
|
||||
AudioLines,
|
||||
AudioWaveform,
|
||||
Undo,
|
||||
Pause,
|
||||
Play,
|
||||
Redo,
|
||||
BanknoteX,
|
||||
ClockAlert,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
// Additional AI module icons
|
||||
Shrub,
|
||||
Lightbulb,
|
||||
Puzzle,
|
||||
Smile,
|
||||
RefreshCcw,
|
||||
LibraryBig,
|
||||
BadgeDollarSign,
|
||||
Newspaper,
|
||||
RectangleHorizontal,
|
||||
RectangleVertical,
|
||||
ImagePlay,
|
||||
ImageOff,
|
||||
Send,
|
||||
FileUp,
|
||||
FileX,
|
||||
DownloadCloud,
|
||||
Link,
|
||||
ScrollText,
|
||||
Mic,
|
||||
MicOff,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Icons as GlobalIcons } from "@turbostarter/ui/assets";
|
||||
|
||||
import type { FC, SVGProps } from "react";
|
||||
|
||||
|
||||
// AI Provider Icons
|
||||
const createProviderIcon = (paths: string[], viewBox = "0 0 24 24"): FC<SVGProps<SVGSVGElement>> => {
|
||||
const Component: FC<SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={viewBox}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
{paths.map((d, i) => (
|
||||
<path key={i} d={d} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
return Component;
|
||||
};
|
||||
|
||||
const OpenAI = createProviderIcon([
|
||||
"M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.677l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.896zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z",
|
||||
]);
|
||||
|
||||
const Gemini = createProviderIcon([
|
||||
"M12 0C5.372 0 0 5.373 0 12s5.372 12 12 12 12-5.373 12-12S18.628 0 12 0zm5.568 14.163c-.169.288-.456.457-.781.457H7.213c-.325 0-.612-.169-.781-.457L4.194 12l2.238-2.163c.169-.288.456-.456.781-.456h9.574c.325 0 .612.168.781.456L19.806 12l-2.238 2.163z",
|
||||
]);
|
||||
|
||||
const Claude = createProviderIcon([
|
||||
"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15.5v-4.38l-3.5 2.19-.75-1.23L10.5 12l-3.75-2.08.75-1.23L11 10.88V6.5h1.5v4.38l3.5-2.19.75 1.23L13 12l3.75 2.08-.75 1.23-3.5-2.19V17.5H11z",
|
||||
]);
|
||||
|
||||
const Grok = createProviderIcon([
|
||||
"M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z",
|
||||
]);
|
||||
|
||||
const DeepSeek = createProviderIcon([
|
||||
"M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8zm4-9h-3V8a1 1 0 0 0-2 0v3H8a1 1 0 0 0 0 2h3v3a1 1 0 0 0 2 0v-3h3a1 1 0 0 0 0-2z",
|
||||
]);
|
||||
|
||||
const Replicate = createProviderIcon([
|
||||
"M4.5 2.5a2 2 0 0 0-2 2v15a2 2 0 0 0 2 2h15a2 2 0 0 0 2-2v-15a2 2 0 0 0-2-2h-15zm2 5h11v2h-11v-2zm0 4h11v2h-11v-2zm0 4h7v2h-7v-2z",
|
||||
]);
|
||||
|
||||
const Luma = createProviderIcon([
|
||||
"M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5",
|
||||
]);
|
||||
|
||||
const StabilityAI = createProviderIcon([
|
||||
"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5-9h10v2H7z",
|
||||
]);
|
||||
|
||||
const Recraft = createProviderIcon([
|
||||
"M20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.996.996 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83zM3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM5.92 19H5v-.92l9.06-9.06.92.92L5.92 19z",
|
||||
]);
|
||||
|
||||
const ElevenLabs = createProviderIcon([
|
||||
"M9 2v20M15 2v20",
|
||||
]);
|
||||
|
||||
const Nvidia = createProviderIcon([
|
||||
"M11.06 8.84c-.6-.3-1.33-.4-2.06-.4-1.91 0-3.47.8-4.3 2.23l-.72-.42c.95-1.66 2.83-2.64 5.02-2.64.87 0 1.72.13 2.46.5l-.4.73zm7.56-3.12c-1.5-1.17-3.57-1.72-5.76-1.72-2.95 0-5.5 1.01-7.05 2.72-.98 1.08-1.59 2.4-1.81 3.87l.82.15c.2-1.32.73-2.5 1.59-3.45 1.4-1.55 3.7-2.45 6.45-2.45 2.02 0 3.9.51 5.24 1.54l.52-.66zM12 22c5.52 0 10-4.48 10-10S17.52 2 12 2 2 6.48 2 12s4.48 10 10 10z",
|
||||
]);
|
||||
|
||||
export const Icons = {
|
||||
...GlobalIcons,
|
||||
Minus,
|
||||
PanelLeft,
|
||||
Eye,
|
||||
Slash,
|
||||
Code,
|
||||
VenetianMask,
|
||||
Lock,
|
||||
MailPlus,
|
||||
ClockFading,
|
||||
Calendar,
|
||||
ArrowRight,
|
||||
LogIn,
|
||||
UserRound,
|
||||
Key,
|
||||
SunMoon,
|
||||
ChevronRight,
|
||||
Moon,
|
||||
Sun,
|
||||
Undo2,
|
||||
Check,
|
||||
Loader2,
|
||||
CircleX,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
CheckIcon,
|
||||
Gift,
|
||||
X,
|
||||
ArrowUp,
|
||||
Globe2,
|
||||
GraduationCap,
|
||||
ChartNoAxesGantt,
|
||||
BookOpen,
|
||||
Webhook,
|
||||
Star,
|
||||
Atom,
|
||||
Brain,
|
||||
LogOut,
|
||||
Settings,
|
||||
Home,
|
||||
CreditCard,
|
||||
User2,
|
||||
EllipsisVertical,
|
||||
LifeBuoy,
|
||||
MessageCircle,
|
||||
Plus,
|
||||
Loader,
|
||||
Ellipsis,
|
||||
Trash,
|
||||
BadgeCheck,
|
||||
BadgeX,
|
||||
ArrowUpRight,
|
||||
Menu,
|
||||
MonitorSmartphone,
|
||||
Download,
|
||||
Copy,
|
||||
PaintBucket,
|
||||
SendHorizontal,
|
||||
ChevronsUpDown,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
UsersRound,
|
||||
UserRoundPlus,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronsLeft,
|
||||
ChevronLeft,
|
||||
ChevronsRight,
|
||||
Settings2,
|
||||
ChevronDown,
|
||||
EyeOff,
|
||||
ChevronUp,
|
||||
XCircle,
|
||||
PlusCircle,
|
||||
ShieldUser,
|
||||
Building,
|
||||
HandCoins,
|
||||
TrendingUp,
|
||||
MoreHorizontal,
|
||||
MoreVertical,
|
||||
Circle,
|
||||
BarChart3,
|
||||
BarChart: BarChart3,
|
||||
Pencil,
|
||||
Clock: ClockFading,
|
||||
Database,
|
||||
Snowflake,
|
||||
Cloud,
|
||||
Crown,
|
||||
Target,
|
||||
Megaphone,
|
||||
Package,
|
||||
Headphones,
|
||||
TrendingDown,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Bell,
|
||||
Share: Share2,
|
||||
Activity,
|
||||
AtSign,
|
||||
MessageSquare,
|
||||
MessageSquarePlus,
|
||||
LayoutDashboard,
|
||||
Edit: Pencil,
|
||||
// AI module icons
|
||||
ImagePlus,
|
||||
Globe,
|
||||
Sparkle,
|
||||
Sparkles,
|
||||
Square,
|
||||
SquarePen,
|
||||
TextSearch,
|
||||
MessagesSquare,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
ChartNoAxesColumn,
|
||||
Zap,
|
||||
PackageOpen,
|
||||
Paperclip,
|
||||
AudioLines,
|
||||
AudioWaveform,
|
||||
Undo,
|
||||
Pause,
|
||||
Play,
|
||||
Redo,
|
||||
BanknoteX,
|
||||
ClockAlert,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Image,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
// Additional AI module icons
|
||||
Shrub,
|
||||
Lightbulb,
|
||||
Puzzle,
|
||||
Smile,
|
||||
RefreshCcw,
|
||||
LibraryBig,
|
||||
BadgeDollarSign,
|
||||
Newspaper,
|
||||
RectangleHorizontal,
|
||||
RectangleVertical,
|
||||
ImagePlay,
|
||||
ImageOff,
|
||||
Send,
|
||||
FileUp,
|
||||
FileUpIcon: FileUp,
|
||||
FileX,
|
||||
DownloadCloud,
|
||||
Link,
|
||||
ScrollText,
|
||||
Mic,
|
||||
MicOff,
|
||||
MinusIcon: Minus,
|
||||
PlusIcon: Plus,
|
||||
// AI provider icons
|
||||
OpenAI,
|
||||
Gemini,
|
||||
Claude,
|
||||
Grok,
|
||||
DeepSeek,
|
||||
Replicate,
|
||||
Luma,
|
||||
StabilityAI,
|
||||
Recraft,
|
||||
ElevenLabs,
|
||||
Nvidia,
|
||||
};
|
||||
|
||||
export type Icon = (typeof Icons)[keyof typeof Icons];
|
||||
77
packages/ui/web/src/components/input-otp.tsx
Normal file
77
packages/ui/web/src/components/input-otp.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-disabled:opacity-50",
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn("flex items-center", className)}
|
||||
{...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
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive border-input data-[active=true]:border-ring data-[active=true]:ring-ring/50 dark:bg-input/30 relative flex size-12 items-center justify-center border-y border-r text-lg shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<Icons.Minus />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
69
packages/ui/web/src/components/input.tsx
Normal file
69
packages/ui/web/src/components/input.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { Button } from "#components/button";
|
||||
import { Icons } from "#components/icons";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input h-10 w-full min-w-0 rounded-md border bg-background px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordInput({ className, ...props }: React.ComponentProps<"input">) {
|
||||
const { t } = useTranslation("auth");
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const disabled =
|
||||
props.value === "" || props.value === undefined || props.disabled;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className={cn("hide-password-toggle pr-10", className)}
|
||||
{...props}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{showPassword && !disabled ? (
|
||||
<Icons.Eye className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Icons.EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{showPassword ? t("hidePassword") : t("showPassword")}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{/* hides browsers password toggles */}
|
||||
<style>{`
|
||||
.hide-password-toggle::-ms-reveal,
|
||||
.hide-password-toggle::-ms-clear {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input, PasswordInput };
|
||||
24
packages/ui/web/src/components/label.tsx
Normal file
24
packages/ui/web/src/components/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { Label as LabelPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
77
packages/ui/web/src/components/marquee.tsx
Normal file
77
packages/ui/web/src/components/marquee.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
interface MarqueeProps extends ComponentPropsWithoutRef<"div"> {
|
||||
/**
|
||||
* Optional CSS class name to apply custom styles
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Whether to reverse the animation direction
|
||||
* @default false
|
||||
*/
|
||||
reverse?: boolean;
|
||||
/**
|
||||
* Whether to pause the animation on hover
|
||||
* @default false
|
||||
*/
|
||||
pauseOnHover?: boolean;
|
||||
/**
|
||||
* Content to be displayed in the marquee
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Whether to animate vertically instead of horizontally
|
||||
* @default false
|
||||
*/
|
||||
vertical?: boolean;
|
||||
/**
|
||||
* Number of times to repeat the content
|
||||
* @default 4
|
||||
*/
|
||||
repeat?: number;
|
||||
}
|
||||
|
||||
export function Marquee({
|
||||
className,
|
||||
reverse = false,
|
||||
pauseOnHover = false,
|
||||
children,
|
||||
vertical = false,
|
||||
repeat = 4,
|
||||
...props
|
||||
}: MarqueeProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
"group flex [gap:var(--gap)] overflow-hidden p-2 [--duration:40s] [--gap:1rem]",
|
||||
{
|
||||
"flex-row": !vertical,
|
||||
"flex-col": vertical,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{Array(repeat)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-around [gap:var(--gap)]",
|
||||
{
|
||||
"animate-marquee flex-row": !vertical,
|
||||
"animate-marquee-vertical flex-col": vertical,
|
||||
"group-hover:[animation-play-state:paused]": pauseOnHover,
|
||||
"[animation-direction:reverse]": reverse,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
packages/ui/web/src/components/modal.tsx
Normal file
168
packages/ui/web/src/components/modal.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { useBreakpoint } from "@turbostarter/ui-web";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "#components/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "#components/drawer";
|
||||
|
||||
interface BaseProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface RootModalProps extends BaseProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface ModalProps extends BaseProps {
|
||||
className?: string;
|
||||
asChild?: true;
|
||||
}
|
||||
|
||||
const ModalContext = React.createContext<{ isDesktop: boolean }>({
|
||||
isDesktop: false,
|
||||
});
|
||||
|
||||
const useModalContext = () => {
|
||||
const context = React.useContext(ModalContext);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"Modal components cannot be rendered outside the Modal Context",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const Modal = ({ children, ...props }: RootModalProps) => {
|
||||
const isDesktop = useBreakpoint("md");
|
||||
const ModalComponent = isDesktop ? Dialog : Drawer;
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={{ isDesktop }}>
|
||||
<ModalComponent {...props} {...(isDesktop && { autoFocus: true })}>
|
||||
{children}
|
||||
</ModalComponent>
|
||||
</ModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ModalTrigger = ({ className, children, ...props }: ModalProps) => {
|
||||
const { isDesktop } = useModalContext();
|
||||
const ModalTriggerComponent = isDesktop ? DialogTrigger : DrawerTrigger;
|
||||
|
||||
return (
|
||||
<ModalTriggerComponent className={className} {...props}>
|
||||
{children}
|
||||
</ModalTriggerComponent>
|
||||
);
|
||||
};
|
||||
|
||||
const ModalClose = ({ className, children, ...props }: ModalProps) => {
|
||||
const { isDesktop } = useModalContext();
|
||||
const ModalCloseComponent = isDesktop ? DialogClose : DrawerClose;
|
||||
|
||||
return (
|
||||
<ModalCloseComponent className={className} {...props}>
|
||||
{children}
|
||||
</ModalCloseComponent>
|
||||
);
|
||||
};
|
||||
|
||||
const ModalContent = ({ className, children, ...props }: ModalProps) => {
|
||||
const { isDesktop } = useModalContext();
|
||||
const ModalContentComponent = isDesktop ? DialogContent : DrawerContent;
|
||||
|
||||
return (
|
||||
<ModalContentComponent className={className} {...props}>
|
||||
{children}
|
||||
</ModalContentComponent>
|
||||
);
|
||||
};
|
||||
|
||||
const ModalDescription = ({ className, children, ...props }: ModalProps) => {
|
||||
const { isDesktop } = useModalContext();
|
||||
const ModalDescriptionComponent = isDesktop
|
||||
? DialogDescription
|
||||
: DrawerDescription;
|
||||
|
||||
return (
|
||||
<ModalDescriptionComponent className={className} {...props}>
|
||||
{children}
|
||||
</ModalDescriptionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
const ModalHeader = ({ className, children, ...props }: ModalProps) => {
|
||||
const { isDesktop } = useModalContext();
|
||||
const ModalHeaderComponent = isDesktop ? DialogHeader : DrawerHeader;
|
||||
|
||||
return (
|
||||
<ModalHeaderComponent className={className} {...props}>
|
||||
{children}
|
||||
</ModalHeaderComponent>
|
||||
);
|
||||
};
|
||||
|
||||
const ModalTitle = ({ className, children, ...props }: ModalProps) => {
|
||||
const { isDesktop } = useModalContext();
|
||||
const ModalTitleComponent = isDesktop ? DialogTitle : DrawerTitle;
|
||||
|
||||
return (
|
||||
<ModalTitleComponent className={className} {...props}>
|
||||
{children}
|
||||
</ModalTitleComponent>
|
||||
);
|
||||
};
|
||||
|
||||
const ModalBody = ({ className, children, ...props }: ModalProps) => {
|
||||
return (
|
||||
<div className={cn("px-4 md:px-0", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ModalFooter = ({ className, children, ...props }: ModalProps) => {
|
||||
const { isDesktop } = useModalContext();
|
||||
const ModalFooterComponent = isDesktop ? DialogFooter : DrawerFooter;
|
||||
|
||||
return (
|
||||
<ModalFooterComponent className={className} {...props}>
|
||||
{children}
|
||||
</ModalFooterComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
Modal,
|
||||
ModalTrigger,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
};
|
||||
168
packages/ui/web/src/components/navigation-menu.tsx
Normal file
168
packages/ui/web/src/components/navigation-menu.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50",
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<Icons.ChevronDown
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 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: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:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center",
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground data-[active=true]:hover:bg-accent data-[active=true]:focus:bg-accent [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
};
|
||||
58
packages/ui/web/src/components/popover.tsx
Normal file
58
packages/ui/web/src/components/popover.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
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 PopoverPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Portal>) {
|
||||
return <PopoverPrimitive.Portal data-slot="popover-portal" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverPortal,
|
||||
};
|
||||
44
packages/ui/web/src/components/radio-group.tsx
Normal file
44
packages/ui/web/src/components/radio-group.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Circle } from "lucide-react";
|
||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
56
packages/ui/web/src/components/resizable.tsx
Normal file
56
packages/ui/web/src/components/resizable.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { GripVertical } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Group, Panel, Separator } from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Group>) {
|
||||
return (
|
||||
<Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof Panel>) {
|
||||
return <Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator> & {
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:left-0 data-[orientation=vertical]:after:h-1 data-[orientation=vertical]:after:w-full data-[orientation=vertical]:after:translate-x-0 data-[orientation=vertical]:after:-translate-y-1/2 [&[data-orientation=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-sm border">
|
||||
<GripVertical className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
143
packages/ui/web/src/components/scroll-area.tsx
Normal file
143
packages/ui/web/src/components/scroll-area.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScrollAreaWithShadowsProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll container with fade indicators.
|
||||
* Uses CSS mask-image to fade content at edges when scrollable.
|
||||
* No overlay elements = no overflow issues with rounded corners.
|
||||
*/
|
||||
function ScrollAreaWithShadows({
|
||||
children,
|
||||
className,
|
||||
maxHeight = "60vh",
|
||||
}: ScrollAreaWithShadowsProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollState, setScrollState] = useState({ top: false, bottom: false });
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const updateScrollState = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||
setScrollState({
|
||||
top: scrollTop > 10,
|
||||
bottom: scrollTop + clientHeight < scrollHeight - 10,
|
||||
});
|
||||
};
|
||||
|
||||
updateScrollState();
|
||||
el.addEventListener("scroll", updateScrollState);
|
||||
const resizeObserver = new ResizeObserver(updateScrollState);
|
||||
resizeObserver.observe(el);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("scroll", updateScrollState);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Build mask based on scroll state
|
||||
const getMaskStyle = (): React.CSSProperties => {
|
||||
const fadeSize = "24px";
|
||||
|
||||
if (scrollState.top && scrollState.bottom) {
|
||||
// Fade both edges
|
||||
return {
|
||||
maskImage: `linear-gradient(to bottom, transparent, black ${fadeSize}, black calc(100% - ${fadeSize}), transparent)`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, transparent, black ${fadeSize}, black calc(100% - ${fadeSize}), transparent)`,
|
||||
};
|
||||
} else if (scrollState.top) {
|
||||
// Fade top only
|
||||
return {
|
||||
maskImage: `linear-gradient(to bottom, transparent, black ${fadeSize})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, transparent, black ${fadeSize})`,
|
||||
};
|
||||
} else if (scrollState.bottom) {
|
||||
// Fade bottom only
|
||||
return {
|
||||
maskImage: `linear-gradient(to bottom, black calc(100% - ${fadeSize}), transparent)`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, black calc(100% - ${fadeSize}), transparent)`,
|
||||
};
|
||||
}
|
||||
|
||||
// No fade needed
|
||||
return {};
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
"overflow-y-auto scroll-smooth [scrollbar-gutter:stable] transition-[mask-image] duration-200",
|
||||
className
|
||||
)}
|
||||
style={{ maxHeight, ...getMaskStyle() }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollAreaWithShadows, ScrollBar };
|
||||
190
packages/ui/web/src/components/select.tsx
Normal file
190
packages/ui/web/src/components/select.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
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 SelectPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Portal>) {
|
||||
return <SelectPrimitive.Portal data-slot="select-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=sm]:h-9 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<Icons.ChevronDown className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Icons.Check 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
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icons.ChevronUp className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icons.ChevronDown className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectPortal,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
28
packages/ui/web/src/components/separator.tsx
Normal file
28
packages/ui/web/src/components/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
146
packages/ui/web/src/components/sheet.tsx
Normal file
146
packages/ui/web/src/components/sheet.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { Dialog as SheetPrimitive, VisuallyHidden } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
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
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
title,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
title?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out 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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<VisuallyHidden.Root>
|
||||
<SheetPrimitive.Title>{title ?? t("menu")}</SheetPrimitive.Title>
|
||||
</VisuallyHidden.Root>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<Icons.X className="size-4" />
|
||||
<span className="sr-only">{t("close")}</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
722
packages/ui/web/src/components/sidebar.tsx
Normal file
722
packages/ui/web/src/components/sidebar.tsx
Normal file
@@ -0,0 +1,722 @@
|
||||
"use client";
|
||||
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
|
||||
import { Button } from "#components/button";
|
||||
import { Input } from "#components/input";
|
||||
import { Separator } from "#components/separator";
|
||||
import { Sheet, SheetContent } from "#components/sheet";
|
||||
import { Skeleton } from "#components/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "#components/tooltip";
|
||||
import { useBreakpoint } from "#hooks/use-media-query";
|
||||
|
||||
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";
|
||||
|
||||
interface 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 = useBreakpoint("md", "max");
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className,
|
||||
)}
|
||||
{...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
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Icons.PanelLeft />
|
||||
<span className="sr-only">{t("toggle")}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label={t("toggle")}
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title={t("toggle")}
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] 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",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full min-w-0 flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden 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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>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 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_hsl(var(--sidebar-border))] 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
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 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 &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
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
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 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",
|
||||
"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,
|
||||
)}
|
||||
{...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,
|
||||
};
|
||||
15
packages/ui/web/src/components/skeleton.tsx
Normal file
15
packages/ui/web/src/components/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
63
packages/ui/web/src/components/slider.tsx
Normal file
63
packages/ui/web/src/components/slider.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { Slider as SliderPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
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
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider };
|
||||
31
packages/ui/web/src/components/switch.tsx
Normal file
31
packages/ui/web/src/components/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Switch as SwitchPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.5rem] w-10 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-5 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
116
packages/ui/web/src/components/table.tsx
Normal file
116
packages/ui/web/src/components/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground px-4 py-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"px-4 py-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
66
packages/ui/web/src/components/tabs.tsx
Normal file
66
packages/ui/web/src/components/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs as TabsPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-10 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-foreground/10 dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
66
packages/ui/web/src/components/text-shimmer.tsx
Normal file
66
packages/ui/web/src/components/text-shimmer.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type {Variants} from "framer-motion";
|
||||
|
||||
interface TextShimmerProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
}
|
||||
|
||||
function TextShimmer({
|
||||
children,
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) {
|
||||
const dynamicSpread = React.useMemo(() => spread, [spread]);
|
||||
|
||||
const variants: Variants = React.useMemo(() => {
|
||||
return {
|
||||
initial: {
|
||||
backgroundPosition: "100% center",
|
||||
},
|
||||
animate: {
|
||||
backgroundPosition: "0% center",
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
className={cn(
|
||||
"relative inline-block bg-clip-text text-transparent",
|
||||
"bg-[length:250%_100%,100%_100%]",
|
||||
"bg-[linear-gradient(90deg,transparent,var(--tw-gradient-from)_calc(50%-var(--shimmer-spread)),var(--tw-gradient-to)_50%,var(--tw-gradient-from)_calc(50%+var(--shimmer-spread)),transparent),linear-gradient(var(--base-gradient-color),var(--base-gradient-color))]",
|
||||
"from-foreground via-foreground/90 to-foreground",
|
||||
"[--base-gradient-color:hsl(var(--muted-foreground)/0.5)]",
|
||||
"[--shimmer-spread:--spacing(8)]",
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--shimmer-spread": `${dynamicSpread}rem`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={variants}
|
||||
transition={{
|
||||
duration,
|
||||
ease: "linear",
|
||||
repeat: Infinity,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export { TextShimmer };
|
||||
21
packages/ui/web/src/components/textarea.tsx
Normal file
21
packages/ui/web/src/components/textarea.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea, TextareaAutosize };
|
||||
89
packages/ui/web/src/components/theme.tsx
Normal file
89
packages/ui/web/src/components/theme.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ThemeColor, ThemeMode } from "@turbostarter/ui";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { ThemeConfig } from "@turbostarter/ui";
|
||||
|
||||
import { Button } from "#components/button";
|
||||
import { Icons } from "#components/icons";
|
||||
import { Label } from "#components/label";
|
||||
|
||||
interface ThemeCustomizerProps {
|
||||
readonly config: ThemeConfig;
|
||||
readonly defaultConfig?: ThemeConfig;
|
||||
readonly onChange: (config: ThemeConfig) => void;
|
||||
}
|
||||
|
||||
const MODE_ICONS = {
|
||||
[ThemeMode.LIGHT]: Icons.Sun,
|
||||
[ThemeMode.DARK]: Icons.Moon,
|
||||
[ThemeMode.SYSTEM]: Icons.SunMoon,
|
||||
} as const;
|
||||
|
||||
export const ThemeCustomizer = memo<ThemeCustomizerProps>(
|
||||
({ config, onChange }) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center space-y-4 md:space-y-6">
|
||||
<div className="w-full space-y-1.5">
|
||||
<Label className="text-xs">{t("theme.color.label")}</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Object.values(ThemeColor).map((color) => {
|
||||
const isActive = config.color === color;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
key={color}
|
||||
onClick={() => onChange({ ...config, color })}
|
||||
className={cn(
|
||||
"justify-start gap-2 text-xs capitalize",
|
||||
isActive && "border-primary dark:border-primary border-2",
|
||||
)}
|
||||
data-theme={color}
|
||||
>
|
||||
<span className="bg-primary flex size-4 shrink-0 items-center justify-center rounded-full"></span>
|
||||
{t(`theme.color.${color}`)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full space-y-1.5">
|
||||
<Label className="text-xs">{t("theme.mode.label")}</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Object.values(ThemeMode).map((mode) => {
|
||||
const isActive = config.mode === mode;
|
||||
const Icon = MODE_ICONS[mode];
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
key={mode}
|
||||
size="sm"
|
||||
onClick={() => onChange({ ...config, mode })}
|
||||
className={cn(
|
||||
"justify-start gap-2 text-xs capitalize",
|
||||
isActive && "border-primary dark:border-primary border-2",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{t(`theme.mode.${mode}`)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ThemeCustomizer.displayName = "ThemeCustomizer";
|
||||
51
packages/ui/web/src/components/toggle.tsx
Normal file
51
packages/ui/web/src/components/toggle.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { cva } from "class-variance-authority";
|
||||
import { Toggle as TogglePrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type {VariantProps} from "class-variance-authority";
|
||||
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface ToggleProps
|
||||
extends React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root>,
|
||||
VariantProps<typeof toggleVariants> {}
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
ToggleProps
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName;
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
61
packages/ui/web/src/components/tooltip.tsx
Normal file
61
packages/ui/web/src/components/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
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
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
48
packages/ui/web/src/hooks/use-media-query.tsx
Normal file
48
packages/ui/web/src/hooks/use-media-query.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { DEFAULT_BREAKPOINTS } from "@turbostarter/ui";
|
||||
|
||||
const getMatches = (query: string): boolean => {
|
||||
if (typeof window !== "undefined") {
|
||||
return window.matchMedia(query).matches;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const useMediaQuery = (query: string) => {
|
||||
const [matches, setMatches] = useState(() => getMatches(query));
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
|
||||
// Sync state with current value after mount
|
||||
setMatches(media.matches);
|
||||
|
||||
const listener = () => {
|
||||
setMatches(media.matches);
|
||||
};
|
||||
|
||||
if (typeof media.addEventListener === "function") {
|
||||
media.addEventListener("change", listener);
|
||||
} else {
|
||||
media.addListener(listener);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof media.removeEventListener === "function") {
|
||||
media.removeEventListener("change", listener);
|
||||
} else {
|
||||
media.removeListener(listener);
|
||||
}
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
export const useBreakpoint = (
|
||||
breakpoint: keyof typeof DEFAULT_BREAKPOINTS,
|
||||
type: "min" | "max" = "min",
|
||||
) => {
|
||||
return useMediaQuery(`(${type}-width: ${DEFAULT_BREAKPOINTS[breakpoint]}px)`);
|
||||
};
|
||||
1
packages/ui/web/src/index.ts
Normal file
1
packages/ui/web/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./hooks/use-media-query";
|
||||
131
packages/ui/web/src/styles/globals.css
Normal file
131
packages/ui/web/src/styles/globals.css
Normal file
@@ -0,0 +1,131 @@
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@theme inline {
|
||||
--animate-marquee: marquee var(--duration) infinite linear;
|
||||
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
|
||||
--animate-fade-in: fade-in 1s var(--animation-delay, 0s) ease forwards;
|
||||
--animate-fade-up: fade-up 1s var(--animation-delay, 0s) ease forwards;
|
||||
--animate-image-glow: image-glow 4.1s ease-out 1s forwards;
|
||||
--animate-pulse-ring: pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
--animate-voice-bars: voice-bars 0.5s ease-in-out infinite;
|
||||
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
@keyframes marquee-vertical {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
translate: 0 -10px;
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
filter: blur(0px);
|
||||
}
|
||||
}
|
||||
@keyframes fade-up {
|
||||
0% {
|
||||
opacity: 0;
|
||||
translate: 0 20px;
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
filter: blur(0px);
|
||||
}
|
||||
}
|
||||
@keyframes image-glow {
|
||||
0% {
|
||||
opacity: 0;
|
||||
animation-timing-function: cubic-bezier(0.74, 0.25, 0.76, 1);
|
||||
}
|
||||
10% {
|
||||
opacity: 0.7;
|
||||
animation-timing-function: cubic-bezier(0.12, 0.01, 0.08, 0.99);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes voice-bars {
|
||||
0%, 100% {
|
||||
transform: scaleY(0.3);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-primary text-background;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply h-2 w-2;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-muted/30;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-muted-foreground/20 rounded-lg;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-muted-foreground/40;
|
||||
}
|
||||
}
|
||||
|
||||
@utility container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-inline: auto;
|
||||
padding-inline: 2rem;
|
||||
|
||||
@variant 2xl {
|
||||
max-width: 1400px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user