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:
dhkatzandClaude Opus 5.5 committed 2026-10-29 05:02:00 -07:00
1 parent 7afada1c63
commit 64c552330b
57 files changed
+3834

No files matched your search

+23
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
}
+68
View File
@@ -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&apos;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>
);
}
+82
View File
@@ -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>;
}
+75
View File
@@ -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>
);
}
+75
View File
@@ -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 }
+57
View File
@@ -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 }
+102
View File
@@ -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,
}
+238
View File
@@ -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,
}
+19
View File
@@ -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 }
+19
View File
@@ -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 }
+60
View File
@@ -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 }
+24
View File
@@ -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 }
+31
View File
@@ -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 }
+115
View File
@@ -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,
}