feat(db): mesh data model — meshes, members, invites, audit log

- pgSchema "mesh" with 4 tables isolating the peer mesh domain
- Enums: visibility, transport, tier, role
- audit_log is metadata-only (E2E encryption enforced at broker/client)
- Cascade on mesh delete, soft-delete via archivedAt/revokedAt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 21:19:32 +01:00
commit d3163a5bff
1384 changed files with 314925 additions and 0 deletions

View 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 };

View 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,
};

View 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 };

View 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 };

View 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 };

View 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 };

View 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,
};

View 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>
);
};

View 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 };

View 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 };

View 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} />
);
}

View 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,
};

View 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 };

View 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,
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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();
}
}

View File

@@ -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>
);
}

View 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>
);
}

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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 };

View 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>
);
};

View 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];

View 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 };

View 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 };

View 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 };

View 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>
);
}

View 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,
};

View 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,
};

View 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,
};

View 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 };

View 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 };

View 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 };

View 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,
};

View 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 };

View 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,
};

View 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,
};

View 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 };

View 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 };

View 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 };

View 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,
};

View 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 };

View 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 };

View 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 };

View 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";

View 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 };

View 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 };

View 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)`);
};

View File

@@ -0,0 +1 @@
export * from "./hooks/use-media-query";

View 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;
}
}