Implement full Couch Potato movie & TV tracking app

Add all 10 milestones: Drizzle ORM + SQLite database with WAL mode,
Better Auth email/password authentication, TMDB API integration for
search and metadata import, TV season/episode caching, user tracking
(watchlist/status/watches/ratings with auto-transitions), discovery
feeds (continue watching, library, recommendations), US streaming
availability via TMDB providers, background job scheduler with
instrumentation hook, and dark cinema-themed frontend with DM Serif
Display + DM Sans typography and amber accent design system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 14:42:13 -05:00
co-authored by Claude Opus 4.6
parent 1b0ca5431a
commit b02ff1cdc1
65 changed files with 4994 additions and 382 deletions
+166
View File
@@ -0,0 +1,166 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { signIn, signUp } from "@/lib/auth/client";
export function AuthForm({ mode }: { mode: "login" | "register" }) {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const isRegister = mode === "register";
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
if (isRegister) {
const result = await signUp.email({ name, email, password });
if (result.error) {
setError(result.error.message ?? "Registration failed");
return;
}
} else {
const result = await signIn.email({ email, password });
if (result.error) {
setError(result.error.message ?? "Login failed");
return;
}
}
router.push("/");
router.refresh();
} catch {
setError("Something went wrong");
} finally {
setLoading(false);
}
}
return (
<div className="relative mx-auto w-full max-w-sm">
{/* Subtle glow behind card */}
<div className="absolute -inset-4 rounded-2xl bg-amber/3 blur-2xl" />
<div className="relative space-y-8 rounded-xl border border-border/50 bg-card/80 p-8 backdrop-blur-sm">
<div className="space-y-2 text-center">
<Link
href="/"
className="font-display text-2xl tracking-tight text-amber"
>
Couch Potato
</Link>
<h1 className="text-lg font-medium">
{isRegister ? "Create your account" : "Welcome back"}
</h1>
<p className="text-sm text-muted-foreground">
{isRegister ? "Start tracking your watches" : "Sign in to continue"}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{isRegister && (
<div className="space-y-1.5">
<label
htmlFor="name"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
>
Name
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-amber/40 focus:outline-none focus:ring-1 focus:ring-amber/20"
placeholder="Your name"
/>
</div>
)}
<div className="space-y-1.5">
<label
htmlFor="email"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
>
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-amber/40 focus:outline-none focus:ring-1 focus:ring-amber/20"
placeholder="you@example.com"
/>
</div>
<div className="space-y-1.5">
<label
htmlFor="password"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
>
Password
</label>
<input
id="password"
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-amber/40 focus:outline-none focus:ring-1 focus:ring-amber/20"
placeholder="Min 8 characters"
/>
</div>
{error && (
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-amber font-medium text-background transition-all hover:shadow-lg hover:shadow-amber/20 disabled:pointer-events-none disabled:opacity-50"
>
{loading ? "Loading..." : isRegister ? "Create account" : "Sign in"}
</button>
</form>
<p className="text-center text-sm text-muted-foreground">
{isRegister ? (
<>
Already have an account?{" "}
<Link
href="/login"
className="font-medium text-amber transition-colors hover:text-amber/80"
>
Sign in
</Link>
</>
) : (
<>
Don&apos;t have an account?{" "}
<Link
href="/register"
className="font-medium text-amber transition-colors hover:text-amber/80"
>
Register
</Link>
</>
)}
</p>
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { IconLogout, IconSearch } from "@tabler/icons-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { signOut, useSession } from "@/lib/auth/client";
export function NavBar() {
const { data: session } = useSession();
const router = useRouter();
const pathname = usePathname();
return (
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
<nav className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
<div className="flex items-center gap-8">
<Link href="/" className="font-display text-xl tracking-tight">
Couch Potato
</Link>
{session?.user && (
<div className="hidden items-center gap-1 sm:flex">
<NavLink href="/search" active={pathname === "/search"}>
<IconSearch size={16} />
<span>Search</span>
</NavLink>
</div>
)}
</div>
<div className="flex items-center gap-3">
{session?.user ? (
<>
<span className="text-sm text-muted-foreground">
{session.user.name}
</span>
<button
type="button"
onClick={async () => {
await signOut();
router.push("/");
router.refresh();
}}
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<IconLogout size={15} />
</button>
</>
) : (
<>
<Link
href="/login"
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
Sign in
</Link>
<Link
href="/register"
className="inline-flex h-8 items-center rounded-md bg-amber px-4 text-sm font-medium text-background transition-all hover:shadow-md hover:shadow-amber/20"
>
Register
</Link>
</>
)}
</div>
</nav>
</header>
);
}
function NavLink({
href,
active,
children,
}: {
href: string;
active: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors ${
active
? "bg-amber/10 text-amber"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{children}
</Link>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { IconSearch } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";
interface SearchBarProps {
onSearch: (query: string) => void;
placeholder?: string;
defaultValue?: string;
}
export function SearchBar({
onSearch,
placeholder = "Search movies & TV shows...",
defaultValue = "",
}: SearchBarProps) {
const [value, setValue] = useState(defaultValue);
const timerRef = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current);
if (!value.trim()) return;
timerRef.current = setTimeout(() => {
onSearch(value.trim());
}, 400);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [value, onSearch]);
return (
<div className="relative">
<IconSearch
size={18}
className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="search"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
className="flex h-13 w-full rounded-xl border border-border/50 bg-card/50 pl-11 pr-4 text-base backdrop-blur-sm transition-all placeholder:text-muted-foreground/50 focus:border-amber/40 focus:outline-none focus:ring-1 focus:ring-amber/20"
/>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { IconStar, IconStarFilled } from "@tabler/icons-react";
import { useState } from "react";
interface StarRatingProps {
value: number;
onChange: (value: number) => void;
}
export function StarRating({ value, onChange }: StarRatingProps) {
const [hover, setHover] = useState(0);
return (
<div
className="flex items-center gap-0.5"
role="radiogroup"
aria-label="Rating"
onMouseLeave={() => setHover(0)}
>
{[1, 2, 3, 4, 5].map((star) => {
const filled = star <= (hover || value);
return (
<button
key={star}
type="button"
onClick={() => onChange(star === value ? 0 : star)}
onMouseEnter={() => setHover(star)}
className="p-0.5 transition-transform hover:scale-110"
>
{filled ? (
<IconStarFilled size={18} className="text-amber" />
) : (
<IconStar size={18} className="text-muted-foreground/30" />
)}
</button>
);
})}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import {
IconBookmark,
IconCheck,
IconPlayerPlay,
IconPlus,
IconX,
} from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";
const statuses = [
{ value: "watchlist", label: "Watchlist", icon: IconBookmark },
{ value: "in_progress", label: "Watching", icon: IconPlayerPlay },
{ value: "completed", label: "Completed", icon: IconCheck },
] as const;
interface StatusButtonProps {
currentStatus: string | null;
onChange: (status: string | null) => void;
}
export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const current = statuses.find((s) => s.value === currentStatus);
const CurrentIcon = current?.icon ?? IconPlus;
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className={`inline-flex h-9 items-center gap-2 rounded-lg border px-4 text-sm font-medium transition-all ${
current
? "border-amber/30 bg-amber/10 text-amber hover:bg-amber/15"
: "border-border/50 hover:border-amber/30 hover:bg-amber/5"
}`}
>
<CurrentIcon size={15} />
{current ? current.label : "Add to List"}
</button>
{open && (
<div className="absolute left-0 top-full z-20 mt-1.5 w-44 overflow-hidden rounded-lg border border-border/50 bg-popover/95 p-1 shadow-xl shadow-black/30 backdrop-blur-xl">
{statuses.map((s) => {
const Icon = s.icon;
return (
<button
key={s.value}
type="button"
onClick={() => {
onChange(s.value === currentStatus ? null : s.value);
setOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent ${
s.value === currentStatus ? "text-amber" : "text-foreground"
}`}
>
<Icon size={15} />
{s.label}
{s.value === currentStatus && (
<IconCheck size={13} className="ml-auto" />
)}
</button>
);
})}
{currentStatus && (
<>
<div className="my-1 border-t border-border/50" />
<button
type="button"
onClick={() => {
onChange(null);
setOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-destructive transition-colors hover:bg-accent"
>
<IconX size={15} />
Remove
</button>
</>
)}
</div>
)}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import Image from "next/image";
import Link from "next/link";
interface TitleCardProps {
id?: string;
tmdbId: number;
type: string;
title: string;
posterPath: string | null;
releaseDate?: string | null;
voteAverage?: number | null;
href?: string;
onImport?: () => void;
}
export function TitleCard({
id,
type,
title,
posterPath,
releaseDate,
voteAverage,
href,
onImport,
}: TitleCardProps) {
const year = releaseDate?.slice(0, 4);
const posterUrl = posterPath
? `https://image.tmdb.org/t/p/w300${posterPath}`
: null;
const content = (
<div className="group relative overflow-hidden rounded-lg transition-transform duration-200 hover:scale-[1.02]">
<div className="aspect-[2/3] overflow-hidden rounded-lg bg-card">
{posterUrl ? (
<Image
src={posterUrl}
alt={title}
width={300}
height={450}
className="h-full w-full object-cover transition-all duration-300 group-hover:brightness-110"
/>
) : (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card to-muted text-sm text-muted-foreground">
No poster
</div>
)}
{/* Overlay gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
</div>
<div className="mt-2 space-y-0.5">
<p className="line-clamp-1 text-sm font-medium leading-snug">{title}</p>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="rounded bg-amber/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber">
{type}
</span>
{year && <span>{year}</span>}
{voteAverage != null && voteAverage > 0 && (
<span className="text-amber"> {voteAverage.toFixed(1)}</span>
)}
</div>
</div>
</div>
);
if (href || id) {
return <Link href={href ?? `/titles/${id}`}>{content}</Link>;
}
if (onImport) {
return (
<button type="button" onClick={onImport} className="w-full text-left">
{content}
</button>
);
}
return content;
}
+26 -27
View File
@@ -1,25 +1,24 @@
"use client"
"use client";
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
);
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
);
}
function AlertDialogOverlay({
@@ -31,11 +30,11 @@ function AlertDialogOverlay({
data-slot="alert-dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogContent({
@@ -43,7 +42,7 @@ function AlertDialogContent({
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
@@ -53,12 +52,12 @@ function AlertDialogContent({
data-size={size}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-none p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm",
className
className,
)}
{...props}
/>
</AlertDialogPortal>
)
);
}
function AlertDialogHeader({
@@ -70,11 +69,11 @@ function AlertDialogHeader({
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogFooter({
@@ -86,11 +85,11 @@ function AlertDialogFooter({
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogMedia({
@@ -102,11 +101,11 @@ function AlertDialogMedia({
data-slot="alert-dialog-media"
className={cn(
"bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-none sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogTitle({
@@ -118,11 +117,11 @@ function AlertDialogTitle({
data-slot="alert-dialog-title"
className={cn(
"text-sm font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogDescription({
@@ -134,11 +133,11 @@ function AlertDialogDescription({
data-slot="alert-dialog-description"
className={cn(
"text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogAction({
@@ -151,7 +150,7 @@ function AlertDialogAction({
className={cn(className)}
{...props}
/>
)
);
}
function AlertDialogCancel({
@@ -168,7 +167,7 @@ function AlertDialogCancel({
render={<Button variant={variant} size={size} />}
{...props}
/>
)
);
}
export {
@@ -184,4 +183,4 @@ export {
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
};
+9 -9
View File
@@ -1,8 +1,8 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"h-5 gap-1 rounded-none border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none 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 overflow-hidden group/badge",
@@ -24,8 +24,8 @@ const badgeVariants = cva(
defaultVariants: {
variant: "default",
},
}
)
},
);
function Badge({
className,
@@ -39,14 +39,14 @@ function Badge({
{
className: cn(badgeVariants({ variant }), className),
},
props
props,
),
render,
state: {
slot: "badge",
variant,
},
})
});
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
+8 -8
View File
@@ -1,9 +1,9 @@
"use client"
"use client";
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
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 dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
@@ -37,8 +37,8 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function Button({
className,
@@ -52,7 +52,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+15 -15
View File
@@ -1,6 +1,6 @@
import * as React from "react"
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Card({
className,
@@ -13,11 +13,11 @@ function Card({
data-size={size}
className={cn(
"ring-foreground/10 bg-card text-card-foreground group/card flex flex-col gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none",
className
className,
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -26,11 +26,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-none px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
className,
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -39,11 +39,11 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-title"
className={cn(
"text-sm font-medium group-data-[size=sm]/card:text-sm",
className
className,
)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -53,7 +53,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-xs/relaxed", className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -62,11 +62,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
className,
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -76,7 +76,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -85,11 +85,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-footer"
className={cn(
"flex items-center rounded-none border-t p-4 group-data-[size=sm]/card:p-3",
className
className,
)}
{...props}
/>
)
);
}
export {
@@ -100,4 +100,4 @@ export {
CardAction,
CardDescription,
CardContent,
}
};
+37 -35
View File
@@ -1,22 +1,21 @@
"use client"
"use client";
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
import { IconCheck, IconChevronDown, IconX } from "@tabler/icons-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { IconChevronDown, IconX, IconCheck } from "@tabler/icons-react"
} from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
const Combobox = ComboboxPrimitive.Root
const Combobox = ComboboxPrimitive.Root;
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
}
function ComboboxTrigger({
@@ -33,7 +32,7 @@ function ComboboxTrigger({
{children}
<IconChevronDown className="text-muted-foreground pointer-events-none size-4" />
</ComboboxPrimitive.Trigger>
)
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
@@ -46,7 +45,7 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
>
<IconX className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
);
}
function ComboboxInput({
@@ -57,8 +56,8 @@ function ComboboxInput({
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
showTrigger?: boolean;
showClear?: boolean;
}) {
return (
<InputGroup className={cn("w-auto", className)}>
@@ -81,7 +80,7 @@ function ComboboxInput({
</InputGroupAddon>
{children}
</InputGroup>
)
);
}
function ComboboxContent({
@@ -110,12 +109,15 @@ function ComboboxContent({
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-none shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none", className )}
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-none shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none",
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
);
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
@@ -124,11 +126,11 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain data-empty:p-0",
className
className,
)}
{...props}
/>
)
);
}
function ComboboxItem({
@@ -141,7 +143,7 @@ function ComboboxItem({
data-slot="combobox-item"
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs 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
className,
)}
{...props}
>
@@ -154,7 +156,7 @@ function ComboboxItem({
<IconCheck className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
);
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
@@ -164,7 +166,7 @@ function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
className={cn(className)}
{...props}
/>
)
);
}
function ComboboxLabel({
@@ -177,13 +179,13 @@ function ComboboxLabel({
className={cn("text-muted-foreground px-2 py-2 text-xs", className)}
{...props}
/>
)
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
);
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
@@ -192,11 +194,11 @@ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
data-slot="combobox-empty"
className={cn(
"text-muted-foreground hidden w-full justify-center py-2 text-center text-xs group-data-empty/combobox-content:flex",
className
className,
)}
{...props}
/>
)
);
}
function ComboboxSeparator({
@@ -209,7 +211,7 @@ function ComboboxSeparator({
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
);
}
function ComboboxChips({
@@ -222,11 +224,11 @@ function ComboboxChips({
data-slot="combobox-chips"
className={cn(
"dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-8 flex-wrap items-center gap-1 rounded-none border bg-transparent bg-clip-padding px-2.5 py-1 text-xs transition-colors focus-within:ring-1 has-aria-invalid:ring-1 has-data-[slot=combobox-chip]:px-1",
className
className,
)}
{...props}
/>
)
);
}
function ComboboxChip({
@@ -235,14 +237,14 @@ function ComboboxChip({
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
showRemove?: boolean;
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"bg-muted text-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-none px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
className,
)}
{...props}
>
@@ -257,7 +259,7 @@ function ComboboxChip({
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
);
}
function ComboboxChipsInput({
@@ -270,11 +272,11 @@ function ComboboxChipsInput({
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
);
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
return React.useRef<HTMLDivElement | null>(null);
}
export {
@@ -294,4 +296,4 @@ export {
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}
};
+40 -40
View File
@@ -1,21 +1,20 @@
"use client"
"use client";
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { IconChevronRight, IconCheck } from "@tabler/icons-react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { IconCheck, IconChevronRight } from "@tabler/icons-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
@@ -41,16 +40,19 @@ function DropdownMenuContent({
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 bg-popover text-popover-foreground data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden", className )}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 bg-popover text-popover-foreground data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
@@ -58,7 +60,7 @@ function DropdownMenuLabel({
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
@@ -66,11 +68,11 @@ function DropdownMenuLabel({
data-inset={inset}
className={cn(
"text-muted-foreground px-2 py-2 text-xs data-inset:pl-7",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuItem({
@@ -79,8 +81,8 @@ function DropdownMenuItem({
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
@@ -89,15 +91,15 @@ function DropdownMenuItem({
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 not-data-[variant=destructive]:focus:**:text-accent-foreground group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-none px-2 py-2 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
@@ -106,7 +108,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
@@ -114,14 +116,14 @@ function DropdownMenuSubTrigger({
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-popup-open:bg-accent data-popup-open:text-accent-foreground flex cursor-default items-center gap-2 rounded-none px-2 py-2 text-xs outline-hidden select-none data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
);
}
function DropdownMenuSubContent({
@@ -137,7 +139,7 @@ function DropdownMenuSubContent({
data-slot="dropdown-menu-sub-content"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 bg-popover text-popover-foreground w-auto min-w-[96px] rounded-none shadow-lg ring-1 duration-100",
className
className,
)}
align={align}
alignOffset={alignOffset}
@@ -145,7 +147,7 @@ function DropdownMenuSubContent({
sideOffset={sideOffset}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -155,7 +157,7 @@ function DropdownMenuCheckboxItem({
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
@@ -163,7 +165,7 @@ function DropdownMenuCheckboxItem({
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
checked={checked}
{...props}
@@ -173,13 +175,12 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<IconCheck
/>
<IconCheck />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
@@ -188,7 +189,7 @@ function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
);
}
function DropdownMenuRadioItem({
@@ -197,7 +198,7 @@ function DropdownMenuRadioItem({
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
@@ -205,7 +206,7 @@ function DropdownMenuRadioItem({
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
@@ -214,13 +215,12 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<IconCheck
/>
<IconCheck />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
);
}
function DropdownMenuSeparator({
@@ -233,7 +233,7 @@ function DropdownMenuSeparator({
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
@@ -245,11 +245,11 @@ function DropdownMenuShortcut({
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest",
className
className,
)}
{...props}
/>
)
);
}
export {
@@ -268,4 +268,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
};
+40 -40
View File
@@ -1,11 +1,10 @@
"use client"
"use client";
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { cva, type VariantProps } from "class-variance-authority";
import { useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
@@ -13,11 +12,11 @@ function FieldSet({ className, ...props }: React.ComponentProps<"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
className,
)}
{...props}
/>
)
);
}
function FieldLegend({
@@ -31,11 +30,11 @@ function FieldLegend({
data-variant={variant}
className={cn(
"mb-2.5 font-medium data-[variant=label]:text-xs data-[variant=legend]:text-sm",
className
className,
)}
{...props}
/>
)
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
@@ -44,11 +43,11 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"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
className,
)}
{...props}
/>
)
);
}
const fieldVariants = cva(
@@ -66,8 +65,8 @@ const fieldVariants = cva(
defaultVariants: {
orientation: "vertical",
},
}
)
},
);
function Field({
className,
@@ -75,6 +74,7 @@ function Field({
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
// biome-ignore lint/a11y/useSemanticElements: shadcn component uses role="group" intentionally
<div
role="group"
data-slot="field"
@@ -82,7 +82,7 @@ function Field({
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -91,11 +91,11 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
className,
)}
{...props}
/>
)
);
}
function FieldLabel({
@@ -108,11 +108,11 @@ function FieldLabel({
className={cn(
"has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
className,
)}
{...props}
/>
)
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -121,11 +121,11 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-xs/relaxed leading-snug group-data-[disabled=true]/field:opacity-50",
className
className,
)}
{...props}
/>
)
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
@@ -136,11 +136,11 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
"text-muted-foreground text-left text-xs/relaxed leading-normal font-normal group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
className,
)}
{...props}
/>
)
);
}
function FieldSeparator({
@@ -148,7 +148,7 @@ function FieldSeparator({
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
children?: React.ReactNode;
}) {
return (
<div
@@ -156,7 +156,7 @@ function FieldSeparator({
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2",
className
className,
)}
{...props}
>
@@ -170,7 +170,7 @@ function FieldSeparator({
</span>
)}
</div>
)
);
}
function FieldError({
@@ -179,37 +179,37 @@ function FieldError({
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children
return children;
}
if (!errors?.length) {
return null
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
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>
(error) =>
error?.message && <li key={error.message}>{error.message}</li>,
)}
</ul>
)
}, [children, errors])
);
}, [children, errors]);
if (!content) {
return null
return null;
}
return (
@@ -221,7 +221,7 @@ function FieldError({
>
{content}
</div>
)
);
}
export {
@@ -235,4 +235,4 @@ export {
FieldSet,
FieldContent,
FieldTitle,
}
};
+28 -26
View File
@@ -1,25 +1,25 @@
"use client"
"use client";
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
// biome-ignore lint/a11y/useSemanticElements: shadcn component uses role="group" intentionally
<div
data-slot="input-group"
role="group"
className={cn(
"border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 group/input-group relative flex h-8 w-full min-w-0 items-center rounded-none border transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot][aria-invalid=true]]:ring-1 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
className,
)}
{...props}
/>
)
);
}
const inputGroupAddonVariants = cva(
@@ -40,8 +40,8 @@ const inputGroupAddonVariants = cva(
defaultVariants: {
align: "inline-start",
},
}
)
},
);
function InputGroupAddon({
className,
@@ -49,6 +49,8 @@ function InputGroupAddon({
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
// biome-ignore lint/a11y/useSemanticElements: shadcn component uses role="group" intentionally
// biome-ignore lint/a11y/useKeyWithClickEvents: click focuses input, keyboard users use tab
<div
role="group"
data-slot="input-group-addon"
@@ -56,13 +58,13 @@ function InputGroupAddon({
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
)
);
}
const inputGroupButtonVariants = cva(
@@ -79,8 +81,8 @@ const inputGroupButtonVariants = cva(
defaultVariants: {
size: "xs",
},
}
)
},
);
function InputGroupButton({
className,
@@ -90,7 +92,7 @@ function InputGroupButton({
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
type?: "button" | "submit" | "reset";
}) {
return (
<Button
@@ -100,7 +102,7 @@ function InputGroupButton({
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
@@ -108,11 +110,11 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
/>
)
);
}
function InputGroupInput({
@@ -124,11 +126,11 @@ function InputGroupInput({
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
className,
)}
{...props}
/>
)
);
}
function InputGroupTextarea({
@@ -140,11 +142,11 @@ function InputGroupTextarea({
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
className,
)}
{...props}
/>
)
);
}
export {
@@ -154,4 +156,4 @@ export {
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
};
+6 -6
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { Input as InputPrimitive } from "@base-ui/react/input";
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
@@ -10,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input"
className={cn(
"dark:bg-input/30 border-input 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:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-none border bg-transparent px-2.5 py-1 text-xs transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-xs file:font-medium focus-visible:ring-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-1 md:text-xs",
className
className,
)}
{...props}
/>
)
);
}
export { Input }
export { Input };
+7 -6
View File
@@ -1,20 +1,21 @@
"use client"
"use client";
import * as React from "react"
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
// biome-ignore lint/a11y/noLabelWithoutControl: generic label component, control association handled by consumer
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-xs leading-none 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
className,
)}
{...props}
/>
)
);
}
export { Label }
export { Label };
+32 -27
View File
@@ -1,12 +1,16 @@
"use client"
"use client";
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { Select as SelectPrimitive } from "@base-ui/react/select";
import {
IconCheck,
IconChevronDown,
IconChevronUp,
IconSelector,
} from "@tabler/icons-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
import { IconSelector, IconCheck, IconChevronUp, IconChevronDown } from "@tabler/icons-react"
const Select = SelectPrimitive.Root
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
@@ -15,7 +19,7 @@ function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
className={cn("scroll-my-1", className)}
{...props}
/>
)
);
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
@@ -25,7 +29,7 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
);
}
function SelectTrigger({
@@ -34,7 +38,7 @@ function SelectTrigger({
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
@@ -42,7 +46,7 @@ function SelectTrigger({
data-size={size}
className={cn(
"border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 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:aria-invalid:border-destructive/50 flex w-fit items-center justify-between gap-1.5 rounded-none border bg-transparent py-2 pr-2 pl-2.5 text-xs whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-1 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
@@ -53,7 +57,7 @@ function SelectTrigger({
}
/>
</SelectPrimitive.Trigger>
)
);
}
function SelectContent({
@@ -83,7 +87,10 @@ function SelectContent({
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none shadow-md ring-1 duration-100 data-[align-trigger=true]:animate-none", className )}
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none shadow-md ring-1 duration-100 data-[align-trigger=true]:animate-none",
className,
)}
{...props}
>
<SelectScrollUpButton />
@@ -92,7 +99,7 @@ function SelectContent({
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
);
}
function SelectLabel({
@@ -105,7 +112,7 @@ function SelectLabel({
className={cn("text-muted-foreground px-2 py-2 text-xs", className)}
{...props}
/>
)
);
}
function SelectItem({
@@ -118,7 +125,7 @@ function SelectItem({
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs 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
className,
)}
{...props}
>
@@ -133,7 +140,7 @@ function SelectItem({
<IconCheck className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
);
}
function SelectSeparator({
@@ -146,7 +153,7 @@ function SelectSeparator({
className={cn("bg-border pointer-events-none -mx-1 h-px", className)}
{...props}
/>
)
);
}
function SelectScrollUpButton({
@@ -158,14 +165,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button"
className={cn(
"bg-popover top-0 z-10 flex w-full cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
<IconChevronUp
/>
<IconChevronUp />
</SelectPrimitive.ScrollUpArrow>
)
);
}
function SelectScrollDownButton({
@@ -177,14 +183,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button"
className={cn(
"bg-popover bottom-0 z-10 flex w-full cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
<IconChevronDown
/>
<IconChevronDown />
</SelectPrimitive.ScrollDownArrow>
)
);
}
export {
@@ -198,4 +203,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
}
};
+6 -6
View File
@@ -1,8 +1,8 @@
"use client"
"use client";
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Separator({
className,
@@ -15,11 +15,11 @@ function Separator({
orientation={orientation}
className={cn(
"bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
className,
)}
{...props}
/>
)
);
}
export { Separator }
export { Separator };
+5 -5
View File
@@ -1,6 +1,6 @@
import * as React from "react"
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
@@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
data-slot="textarea"
className={cn(
"border-input dark:bg-input/30 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:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full rounded-none border bg-transparent px-2.5 py-2 text-xs transition-colors outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-1 md:text-xs",
className
className,
)}
{...props}
/>
)
);
}
export { Textarea }
export { Textarea };