Website: Next.js app for accounts, linking, who's online, device sign-in and the console
apps/web: Next.js 16 (App Router), Tailwind 4 and shadcn/ui, light and dark from the system. Pages render on the server and call the API with Eden Treaty (typed by @t5de/api), forwarding the visitor's cookie; forms use server actions; sign-up, sign-in and sign-out use Better Auth's React client. /api is rewritten to the API so its cookies are first-party. Who's online follows the API's event stream (Next's gzip is off: it held the stream back). Standalone output with a Dockerfile; deploys to Vercel as is. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7afada1c63
commit
64c552330b
57 files changed
+3834
No files matched your search
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
// Forms that run a server action and show its answer, with buttons that show when they're working.
|
||||
import { useActionState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ActionState } from "@/lib/actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Action = (state: ActionState, form: FormData) => Promise<ActionState>;
|
||||
|
||||
export function ActionForm({
|
||||
action,
|
||||
fields = {},
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
action: Action;
|
||||
/** Hidden fields sent with the form. */
|
||||
fields?: Record<string, string | number>;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [state, formAction] = useActionState(action, null);
|
||||
return (
|
||||
<form action={formAction} className={cn("space-y-3", className)}>
|
||||
{Object.entries(fields).map(([name, value]) => (
|
||||
<input key={name} type="hidden" name={name} value={value} />
|
||||
))}
|
||||
{children}
|
||||
<FormMessage state={state} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormMessage({ state }: { state: ActionState }) {
|
||||
if (state?.error) return <ErrorText>{state.error}</ErrorText>;
|
||||
if (state?.notice)
|
||||
return (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{state.notice}
|
||||
</p>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ErrorText({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p role="alert" className="text-sm text-red-700 dark:text-red-400">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A submit button that shows pending while its form is being sent. With name and value, it's one of several
|
||||
* buttons in the form, and only shows pending when it was the one pressed. With confirm, it asks first.
|
||||
*/
|
||||
export function SubmitButton({
|
||||
name,
|
||||
value,
|
||||
pendingLabel,
|
||||
confirm,
|
||||
children,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "type" | "name" | "value" | "onClick"> & {
|
||||
name?: string;
|
||||
value?: string;
|
||||
pendingLabel?: string;
|
||||
confirm?: string;
|
||||
}) {
|
||||
const status = useFormStatus();
|
||||
const mine = status.pending && (name === undefined || status.data?.get(name) === value);
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
name={name}
|
||||
value={value}
|
||||
disabled={status.pending}
|
||||
onClick={(e) => {
|
||||
if (confirm && !window.confirm(confirm)) e.preventDefault();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{mine ? (pendingLabel ?? children) : children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** A link in a navigation bar, marked when it's the current page (or, unless exact, a page under it). */
|
||||
export function NavLink({
|
||||
href,
|
||||
exact = false,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
exact?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const here = pathname === href || (!exact && pathname.startsWith(href + "/"));
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-current={here ? "page" : undefined}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1.5 text-muted-foreground transition-colors hover:text-foreground",
|
||||
here && "bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function SignOutButton() {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={async () => {
|
||||
setPending(true);
|
||||
await authClient.signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
{pending ? "Signing out…" : "Sign out"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Link from "next/link";
|
||||
import { getMe, isModerator } from "@/lib/api";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { NavLink, SignOutButton } from "./header-client";
|
||||
|
||||
export function Logo() {
|
||||
return (
|
||||
<span
|
||||
className="grid size-7 place-items-center rounded-md bg-foreground text-[11px] font-bold tracking-tight text-background"
|
||||
aria-hidden
|
||||
>
|
||||
T5
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export async function Header() {
|
||||
const me = await getMe();
|
||||
return (
|
||||
<header className="border-b">
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6">
|
||||
<Link href="/" className="flex items-center gap-2 font-semibold">
|
||||
<Logo />
|
||||
T5DE Hub
|
||||
</Link>
|
||||
<nav className="order-last flex w-full items-center gap-1 text-sm sm:order-none sm:w-auto">
|
||||
<NavLink href="/online">Online</NavLink>
|
||||
{me && <NavLink href="/account">Account</NavLink>}
|
||||
{isModerator(me) && <NavLink href="/console">Console</NavLink>}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2 text-sm">
|
||||
{me ? (
|
||||
<>
|
||||
<span className="hidden text-muted-foreground sm:inline">{me.username}</span>
|
||||
<SignOutButton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/signin" className={buttonVariants({ variant: "ghost" })}>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link href="/signup" className={buttonVariants()}>
|
||||
Create account
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
/** A small centered card: sign-in, sign-up, device sign-in, errors. */
|
||||
export function NarrowCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="mx-auto w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
{children && <CardContent>{children}</CardContent>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
// Who's online in T5DE, kept live: the page renders the list, then this follows the API's event stream.
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Initial, Outfit, Tag } from "@/components/status";
|
||||
import { Since } from "@/components/time";
|
||||
import type { OnlineEntry } from "@/lib/api";
|
||||
|
||||
export function OnlineList({ initial, moderator = false }: { initial: OnlineEntry[]; moderator?: boolean }) {
|
||||
const [online, setOnline] = useState(initial);
|
||||
const [live, setLive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const events = new EventSource("/api/v1/online/events");
|
||||
events.addEventListener("online", (e) => {
|
||||
setOnline((JSON.parse(e.data) as { online: OnlineEntry[] }).online);
|
||||
setLive(true);
|
||||
});
|
||||
events.addEventListener("error", () => setLive(false));
|
||||
return () => events.close();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span
|
||||
className={live ? "size-2 rounded-full bg-emerald-500" : "size-2 rounded-full bg-muted-foreground/40"}
|
||||
aria-hidden
|
||||
/>
|
||||
{live ? "Live" : "Connecting…"}
|
||||
<span className="ml-auto tabular-nums">
|
||||
{online.length} {online.length === 1 ? "account" : "accounts"}
|
||||
</span>
|
||||
</div>
|
||||
{online.length === 0 ? (
|
||||
<p className="py-6 text-sm text-muted-foreground">Nobody is showing that they're online right now.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{online.map((o) => (
|
||||
<li key={o.imvuId} className="flex gap-4 py-4 first:pt-0 last:pb-0">
|
||||
<Initial name={o.username} />
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{o.username}</span>
|
||||
{o.badge && <Tag tone="strong">T5DE</Tag>}
|
||||
<Tag tone="online" dot>
|
||||
online since <Since iso={o.since} />
|
||||
</Tag>
|
||||
{o.hidden && <Tag>hidden: moderators only</Tag>}
|
||||
{moderator && o.adult && <Tag tone="adult">adult</Tag>}
|
||||
{moderator && o.personId && (
|
||||
<Link
|
||||
href={`/console/people/${o.personId}`}
|
||||
className="text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
person
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Outfit items={o.outfit} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Small labels. Color only means something: green is online, blue is age verified or adult, red is banned.
|
||||
import type { Link } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const tones = {
|
||||
neutral: "border-border bg-muted/60 text-muted-foreground",
|
||||
strong: "border-foreground/15 bg-foreground/5 text-foreground",
|
||||
online: "border-emerald-600/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
||||
adult: "border-sky-600/20 bg-sky-500/10 text-sky-700 dark:text-sky-400",
|
||||
danger: "border-red-600/20 bg-red-500/10 text-red-700 dark:text-red-400",
|
||||
};
|
||||
|
||||
export function Tag({
|
||||
tone = "neutral",
|
||||
dot = false,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tone?: keyof typeof tones;
|
||||
dot?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-6 items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium whitespace-nowrap",
|
||||
tones[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{dot && <span className="size-1.5 rounded-full bg-current" aria-hidden />}
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A round initial standing in for an avatar picture. */
|
||||
export function Initial({ name }: { name: string }) {
|
||||
return (
|
||||
<span
|
||||
className="grid size-10 shrink-0 place-items-center rounded-full border bg-muted text-sm font-semibold text-muted-foreground uppercase"
|
||||
aria-hidden
|
||||
>
|
||||
{name.slice(0, 1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The community products an account wears. */
|
||||
export function Outfit({ items }: { items: { id: string; name: string }[] }) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">Wearing</div>
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="rounded-md border bg-background px-2 py-0.5 text-xs">
|
||||
{item.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A page's title and the line under it. */
|
||||
export function PageHeader({ title, children }: { title: React.ReactNode; children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-8 space-y-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h1>
|
||||
{children && <div className="text-muted-foreground">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An IMVU account's age status: blue when it can see adult community products. */
|
||||
export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass"> }) {
|
||||
if (link.ageVerified) return <Tag tone="adult">Age verified</Tag>;
|
||||
if (link.accessPass) return <Tag tone="adult">Access Pass</Tag>;
|
||||
return <Tag>Not age verified</Tag>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
// Times in the visitor's own time zone and language. The server doesn't know either, so it renders times in UTC and
|
||||
// the browser replaces them once the page is running.
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
|
||||
const noSubscription = () => () => {};
|
||||
|
||||
/** False on the server and while hydrating, true after. */
|
||||
function useInBrowser(): boolean {
|
||||
return useSyncExternalStore(
|
||||
noSubscription,
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
const units: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
["year", 365 * 24 * 3600],
|
||||
["month", 30 * 24 * 3600],
|
||||
["week", 7 * 24 * 3600],
|
||||
["day", 24 * 3600],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
];
|
||||
const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
|
||||
function ago(iso: string, now: number): string {
|
||||
const seconds = (new Date(iso).getTime() - now) / 1000;
|
||||
for (const [unit, size] of units) {
|
||||
if (Math.abs(seconds) >= size) return relative.format(Math.round(seconds / size), unit);
|
||||
}
|
||||
return "just now";
|
||||
}
|
||||
|
||||
/** "5 minutes ago", kept up to date. */
|
||||
export function TimeAgo({ iso }: { iso: string }) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
const local = useInBrowser();
|
||||
return (
|
||||
<time dateTime={iso} title={format(iso, { dateStyle: "medium", timeStyle: "short" }, local)}>
|
||||
{local ? ago(iso, now) : format(iso, { dateStyle: "medium" }, false)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
function format(iso: string, options: Intl.DateTimeFormatOptions, local: boolean): string {
|
||||
return new Date(iso).toLocaleString(local ? undefined : "en", local ? options : { ...options, timeZone: "UTC" });
|
||||
}
|
||||
|
||||
/** A date (and time). */
|
||||
export function DateTime({ iso, dateOnly = false }: { iso: string; dateOnly?: boolean }) {
|
||||
const local = useInBrowser();
|
||||
const options: Intl.DateTimeFormatOptions = dateOnly
|
||||
? { dateStyle: "medium" }
|
||||
: { dateStyle: "medium", timeStyle: "short" };
|
||||
return <time dateTime={iso}>{format(iso, options, local)}</time>;
|
||||
}
|
||||
|
||||
/** A time of day ("3:04 PM"), with the date too when it isn't today. */
|
||||
export function Since({ iso }: { iso: string }) {
|
||||
const local = useInBrowser();
|
||||
const today = local && new Date(iso).toDateString() === new Date().toDateString();
|
||||
const options: Intl.DateTimeFormatOptions = today
|
||||
? { hour: "numeric", minute: "2-digit" }
|
||||
: { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" };
|
||||
return (
|
||||
<time dateTime={iso} title={format(iso, { dateStyle: "medium", timeStyle: "short" }, local)}>
|
||||
{format(iso, options, local)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
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(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
|
||||
size?: "sm" | "default"
|
||||
}
|
||||
|
||||
function NativeSelect({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: NativeSelectProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
data-slot="native-select-wrapper"
|
||||
data-size={size}
|
||||
>
|
||||
<select
|
||||
data-slot="native-select"
|
||||
data-size={size}
|
||||
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
|
||||
{...props}
|
||||
/>
|
||||
<ChevronDownIcon className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none" aria-hidden="true" data-slot="native-select-icon" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NativeSelectOption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"option">) {
|
||||
return (
|
||||
<option
|
||||
data-slot="native-select-option"
|
||||
className={cn("bg-[Canvas] text-[CanvasText]", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NativeSelectOptGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"optgroup">) {
|
||||
return (
|
||||
<optgroup
|
||||
data-slot="native-select-optgroup"
|
||||
className={cn("bg-[Canvas] text-[CanvasText]", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: SwitchPrimitive.Root.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
Reference in new issue
Block a user