Initial commit: WaMarket MVP

Marketplace inversée (Next.js 16 App Router + Supabase Cloud) : auth,
schéma + RLS, publication de besoins avec upload d'images, feed avec
filtres, offres, acceptation via RPC, messagerie temps réel,
dashboards, et Dockerfile pour déploiement Coolify.
This commit is contained in:
2026-07-30 14:33:17 +00:00
commit af81124d48
82 changed files with 16824 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { login } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function LoginForm() {
const [state, action, pending] = useActionState(login, undefined);
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Se connecter</CardTitle>
<CardDescription>Accède à tes besoins et tes offres.</CardDescription>
</CardHeader>
<CardContent>
<form action={action} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" placeholder="vous@exemple.com" />
{state?.errors?.email && (
<p className="text-sm text-destructive">{state.errors.email[0]}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Mot de passe</Label>
<Input id="password" name="password" type="password" />
{state?.errors?.password && (
<p className="text-sm text-destructive">
{state.errors.password[0]}
</p>
)}
</div>
{state?.message && (
<p className="text-sm text-destructive">{state.message}</p>
)}
<Button type="submit" disabled={pending} className="mt-2">
{pending ? "Connexion..." : "Se connecter"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Pas encore de compte ?{" "}
<Link href="/auth/signup" className="underline">
S&apos;inscrire
</Link>
</p>
</CardContent>
</Card>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { signup } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function SignupForm() {
const [state, action, pending] = useActionState(signup, undefined);
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Créer un compte</CardTitle>
<CardDescription>
Postez un besoin ou répondez aux besoins des autres.
</CardDescription>
</CardHeader>
<CardContent>
<form action={action} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="fullName">Nom complet</Label>
<Input id="fullName" name="fullName" placeholder="Jean Dupont" />
{state?.errors?.fullName && (
<p className="text-sm text-destructive">
{state.errors.fullName[0]}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" placeholder="vous@exemple.com" />
{state?.errors?.email && (
<p className="text-sm text-destructive">{state.errors.email[0]}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Mot de passe</Label>
<Input id="password" name="password" type="password" />
{state?.errors?.password && (
<ul className="text-sm text-destructive">
{state.errors.password.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
</div>
{state?.message && (
<p className="text-sm text-destructive">{state.message}</p>
)}
<Button type="submit" disabled={pending} className="mt-2">
{pending ? "Création..." : "Créer mon compte"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Déjà un compte ?{" "}
<Link href="/auth/login" className="underline">
Se connecter
</Link>
</p>
</CardContent>
</Card>
);
}
+10
View File
@@ -0,0 +1,10 @@
export function Footer() {
return (
<footer className="border-t py-6">
<div className="mx-auto max-w-5xl px-4 text-center text-sm text-muted-foreground sm:px-6">
© {new Date().getFullYear()} WaMarket Postez un besoin, recevez des
offres.
</div>
</footer>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Menu, X } from "lucide-react";
import { logout } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type NavUser = { id: string; email?: string } | null;
const loggedInLinks = [
{ href: "/needs", label: "Besoins" },
{ href: "/dashboard/my-needs", label: "Mes besoins" },
{ href: "/dashboard/my-offers", label: "Mes offres" },
{ href: "/messages", label: "Messages" },
];
const loggedOutLinks = [{ href: "/needs", label: "Besoins" }];
export function Navbar({ user }: { user: NavUser }) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const links = user ? loggedInLinks : loggedOutLinks;
return (
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4 sm:px-6">
<Link href="/" className="font-semibold tracking-tight" onClick={() => setOpen(false)}>
WaMarket
</Link>
<nav className="hidden items-center gap-6 text-sm font-medium md:flex">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
className={cn(
"text-muted-foreground transition-colors hover:text-foreground",
pathname === link.href && "text-foreground"
)}
>
{link.label}
</Link>
))}
</nav>
<div className="hidden items-center gap-2 md:flex">
{user ? (
<>
<Button asChild size="sm">
<Link href="/needs/new">Publier un besoin</Link>
</Button>
<form action={logout}>
<Button variant="ghost" size="sm" type="submit">
Se déconnecter
</Button>
</form>
</>
) : (
<>
<Button asChild variant="ghost" size="sm">
<Link href="/auth/login">Se connecter</Link>
</Button>
<Button asChild size="sm">
<Link href="/auth/signup">S&apos;inscrire</Link>
</Button>
</>
)}
</div>
<button
type="button"
aria-label="Ouvrir le menu"
className="flex h-9 w-9 items-center justify-center rounded-md md:hidden"
onClick={() => setOpen((v) => !v)}
>
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
{open && (
<div className="border-t px-4 py-4 md:hidden">
<nav className="flex flex-col gap-3 text-sm font-medium">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setOpen(false)}
className="text-muted-foreground hover:text-foreground"
>
{link.label}
</Link>
))}
</nav>
<div className="mt-4 flex flex-col gap-2">
{user ? (
<>
<Button asChild size="sm" onClick={() => setOpen(false)}>
<Link href="/needs/new">Publier un besoin</Link>
</Button>
<form action={logout}>
<Button variant="ghost" size="sm" type="submit" className="w-full">
Se déconnecter
</Button>
</form>
</>
) : (
<>
<Button asChild variant="ghost" size="sm" onClick={() => setOpen(false)}>
<Link href="/auth/login">Se connecter</Link>
</Button>
<Button asChild size="sm" onClick={() => setOpen(false)}>
<Link href="/auth/signup">S&apos;inscrire</Link>
</Button>
</>
)}
</div>
</div>
)}
</header>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { sendMessage } from "@/lib/actions/messages";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type Message = {
id: string;
conversation_id: string;
sender_id: string;
body: string;
created_at: string;
};
export function ChatWindow({
conversationId,
currentUserId,
initialMessages,
}: {
conversationId: string;
currentUserId: string;
initialMessages: Message[];
}) {
const [messages, setMessages] = useState<Message[]>(initialMessages);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel(`conversation:${conversationId}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "messages",
filter: `conversation_id=eq.${conversationId}`,
},
(payload) => {
const newMessage = payload.new as Message;
setMessages((current) => {
if (current.some((m) => m.id === newMessage.id)) return current;
return [...current, newMessage];
});
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [conversationId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const sendMessageWithConversation = sendMessage.bind(null, conversationId);
return (
<div className="flex h-[60vh] flex-col rounded-lg border sm:h-[70vh]">
<div className="flex-1 space-y-3 overflow-y-auto p-4">
{messages.map((message) => {
const isMine = message.sender_id === currentUserId;
return (
<div
key={message.id}
className={cn("flex", isMine ? "justify-end" : "justify-start")}
>
<div
className={cn(
"max-w-[75%] rounded-lg px-3 py-2 text-sm",
isMine
? "bg-primary text-primary-foreground"
: "bg-muted"
)}
>
{message.body}
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
<form
action={async (formData) => {
await sendMessageWithConversation(formData);
(document.getElementById("chat-input") as HTMLInputElement).value =
"";
}}
className="flex gap-2 border-t p-3"
>
<Input
id="chat-input"
name="body"
placeholder="Écris un message..."
autoComplete="off"
className="flex-1"
/>
<Button type="submit">Envoyer</Button>
</form>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import Link from "next/link";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
type NeedCardData = {
id: string;
title: string;
city: string;
region: string | null;
budget_min: number | null;
budget_max: number | null;
categories: { name: string } | { name: string }[] | null;
need_images: { storage_path: string; position: number }[] | null;
};
export function NeedCard({
need,
getImageUrl,
}: {
need: NeedCardData;
getImageUrl: (path: string) => string;
}) {
const category = Array.isArray(need.categories)
? need.categories[0]
: need.categories;
const images = [...(need.need_images ?? [])].sort(
(a, b) => a.position - b.position
);
const cover = images[0];
return (
<Link href={`/needs/${need.id}`}>
<Card className="h-full transition-shadow hover:shadow-md">
{cover && (
<div className="relative h-40 w-full overflow-hidden rounded-t-xl">
<Image
src={getImageUrl(cover.storage_path)}
alt={need.title}
fill
className="object-cover"
/>
</div>
)}
<CardContent className="flex flex-col gap-2 pt-4">
{category?.name && (
<Badge variant="secondary" className="w-fit">
{category.name}
</Badge>
)}
<h3 className="line-clamp-2 font-medium">{need.title}</h3>
<p className="text-sm text-muted-foreground">
{need.city}
{need.region ? `, ${need.region}` : ""}
</p>
{(need.budget_min || need.budget_max) && (
<p className="text-sm">
{need.budget_min ?? "?"} {need.budget_max ?? "?"}
</p>
)}
</CardContent>
</Card>
</Link>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type Category = { id: string; name: string; slug: string };
export function NeedFilters({
categories,
defaultValues,
}: {
categories: Category[];
defaultValues: { category?: string; city?: string; q?: string };
}) {
return (
<form className="flex flex-wrap gap-3" method="get">
<Input
name="q"
placeholder="Rechercher un mot-clé..."
defaultValue={defaultValues.q}
className="w-full sm:w-56"
/>
<Input
name="city"
placeholder="Ville"
defaultValue={defaultValues.city}
className="w-full sm:w-40"
/>
<Select name="category" defaultValue={defaultValues.category}>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder="Toutes catégories" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.id} value={category.slug}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="submit" variant="secondary">
Filtrer
</Button>
</form>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useActionState } from "react";
import { createNeed } from "@/lib/actions/needs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ImageUploader } from "@/components/shared/image-uploader";
type Category = { id: string; name: string; slug: string };
export function NeedForm({ categories }: { categories: Category[] }) {
const [state, action, pending] = useActionState(createNeed, undefined);
return (
<form action={action} className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="title">Titre</Label>
<Input
id="title"
name="title"
placeholder="Ex: Besoin d'un plombier pour une fuite"
/>
{state?.errors?.title && (
<p className="text-sm text-destructive">{state.errors.title[0]}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
rows={5}
placeholder="Décris ton besoin en détail : contexte, urgence, contraintes..."
/>
{state?.errors?.description && (
<p className="text-sm text-destructive">
{state.errors.description[0]}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="categoryId">Catégorie</Label>
<Select name="categoryId">
<SelectTrigger id="categoryId" className="w-full">
<SelectValue placeholder="Choisis une catégorie" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
{state?.errors?.categoryId && (
<p className="text-sm text-destructive">
{state.errors.categoryId[0]}
</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="city">Ville</Label>
<Input id="city" name="city" placeholder="Paris" />
{state?.errors?.city && (
<p className="text-sm text-destructive">{state.errors.city[0]}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="region">Région (optionnel)</Label>
<Input id="region" name="region" placeholder="Île-de-France" />
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="budgetMin">Budget min (optionnel, )</Label>
<Input id="budgetMin" name="budgetMin" type="number" min="0" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="budgetMax">Budget max (optionnel, )</Label>
<Input id="budgetMax" name="budgetMax" type="number" min="0" />
</div>
</div>
<ImageUploader name="images" maxFiles={5} />
{state?.message && (
<p className="text-sm text-destructive">{state.message}</p>
)}
<Button type="submit" disabled={pending} className="mt-2 w-fit">
{pending ? "Publication..." : "Publier le besoin"}
</Button>
</form>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useActionState } from "react";
import { createOffer } from "@/lib/actions/offers";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { ImageUploader } from "@/components/shared/image-uploader";
export function OfferForm({ needId }: { needId: string }) {
const [state, action, pending] = useActionState(createOffer, undefined);
return (
<form action={action} className="flex flex-col gap-4">
<input type="hidden" name="needId" value={needId} />
<div className="flex flex-col gap-2">
<Label htmlFor="offer-title">Titre de l&apos;offre</Label>
<Input
id="offer-title"
name="title"
placeholder="Ex: Intervention sous 48h avec garantie"
/>
{state?.errors?.title && (
<p className="text-sm text-destructive">{state.errors.title[0]}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="offer-description">Description</Label>
<Textarea
id="offer-description"
name="description"
rows={4}
placeholder="Détaille ce que tu proposes, tes conditions, ton expérience..."
/>
{state?.errors?.description && (
<p className="text-sm text-destructive">
{state.errors.description[0]}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="offer-price">Prix ()</Label>
<Input id="offer-price" name="price" type="number" min="0" step="0.01" />
{state?.errors?.price && (
<p className="text-sm text-destructive">{state.errors.price[0]}</p>
)}
</div>
<ImageUploader name="images" maxFiles={5} />
{state?.message && (
<p className="text-sm text-destructive">{state.message}</p>
)}
<Button type="submit" disabled={pending} className="mt-2 w-fit">
{pending ? "Envoi..." : "Envoyer l'offre"}
</Button>
</form>
);
}
+109
View File
@@ -0,0 +1,109 @@
import Image from "next/image";
import { acceptOffer } from "@/lib/actions/offers";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
type Offer = {
id: string;
title: string;
description: string;
price: number;
status: string;
merchant_id: string;
profiles: { full_name: string | null } | { full_name: string | null }[] | null;
offer_images: { storage_path: string; position: number }[] | null;
};
const STATUS_LABEL: Record<string, string> = {
pending: "En attente",
accepted: "Acceptée",
rejected: "Refusée",
withdrawn: "Retirée",
};
export function OfferList({
offers,
needId,
needStatus,
isOwner,
getImageUrl,
}: {
offers: Offer[];
needId: string;
needStatus: string;
isOwner: boolean;
getImageUrl: (path: string) => string;
}) {
if (offers.length === 0) {
return (
<p className="mt-4 text-sm text-muted-foreground">
Aucune offre reçue pour le moment.
</p>
);
}
return (
<div className="mt-4 flex flex-col gap-4">
{offers.map((offer) => {
const merchant = Array.isArray(offer.profiles)
? offer.profiles[0]
: offer.profiles;
const images = [...(offer.offer_images ?? [])].sort(
(a, b) => a.position - b.position
);
return (
<Card key={offer.id}>
<CardContent className="flex flex-col gap-2 pt-4">
<div className="flex items-center justify-between">
<span className="font-medium">
{merchant?.full_name ?? "Commerçant"}
</span>
<Badge
variant={offer.status === "accepted" ? "default" : "outline"}
>
{STATUS_LABEL[offer.status] ?? offer.status}
</Badge>
</div>
<h4 className="font-medium">{offer.title}</h4>
<p className="text-sm text-muted-foreground">
{offer.description}
</p>
<p className="font-semibold">{offer.price} </p>
{images.length > 0 && (
<div className="mt-1 flex flex-wrap gap-2">
{images.map((img) => (
<div
key={img.storage_path}
className="relative h-16 w-16 overflow-hidden rounded-md border"
>
<Image
src={getImageUrl(img.storage_path)}
alt={offer.title}
fill
className="object-cover"
/>
</div>
))}
</div>
)}
{isOwner && needStatus === "open" && offer.status === "pending" && (
<form
action={acceptOffer.bind(null, offer.id, needId)}
className="mt-2"
>
<Button type="submit" size="sm">
Accepter cette offre
</Button>
</form>
)}
</CardContent>
</Card>
);
})}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
export function ImageUploader({
name,
maxFiles = 5,
}: {
name: string;
maxFiles?: number;
}) {
const [previews, setPreviews] = useState<string[]>([]);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []).slice(0, maxFiles);
setPreviews((old) => {
old.forEach((url) => URL.revokeObjectURL(url));
return files.map((file) => URL.createObjectURL(file));
});
}
return (
<div className="flex flex-col gap-2">
<Label htmlFor={name}>Photos (optionnel, {maxFiles} max)</Label>
<Input
id={name}
name={name}
type="file"
accept="image/*"
multiple
onChange={handleChange}
/>
{previews.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{previews.map((src, i) => (
<div
key={src}
className="relative h-20 w-20 overflow-hidden rounded-md border"
>
<Image
src={src}
alt={`Aperçu ${i + 1}`}
fill
className="object-cover"
unoptimized
/>
</div>
))}
</div>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 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/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", 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-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+257
View File
@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+167
View File
@@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import type { Label as LabelPrimitive } from "radix-ui"
import { Slot } from "radix-ui"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot.Root>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot.Root
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-sm text-destructive", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+190
View File
@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+40
View File
@@ -0,0 +1,40 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }