Initial commit: LeadFlow lead generation platform
Full-stack Next.js 16 app with three scraping pipelines: - AirScale CSV → Anymailfinder Bulk Decision Maker search - LinkedIn Sales Navigator → Vayne → Anymailfinder email enrichment - Apify Google SERP → domain extraction → Anymailfinder bulk enrichment Includes Docker multi-stage build + docker-compose for Coolify deployment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
101
components/layout/Sidebar.tsx
Normal file
101
components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Building2, Linkedin, Search, BarChart3, Settings, Zap, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useAppStore } from "@/lib/store";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/airscale", icon: Building2, label: "AirScale → Email", color: "text-blue-400" },
|
||||
{ href: "/linkedin", icon: Linkedin, label: "LinkedIn → Email", color: "text-blue-500" },
|
||||
{ href: "/serp", icon: Search, label: "SERP → Email", color: "text-purple-400" },
|
||||
{ href: "/results", icon: BarChart3, label: "Results & History", color: "text-green-400" },
|
||||
{ href: "/settings", icon: Settings, label: "Settings", color: "text-gray-400" },
|
||||
];
|
||||
|
||||
interface CredentialStatus {
|
||||
anymailfinder: boolean;
|
||||
apify: boolean;
|
||||
vayne: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { sidebarCollapsed, setSidebarCollapsed } = useAppStore();
|
||||
const [creds, setCreds] = useState<CredentialStatus>({ anymailfinder: false, apify: false, vayne: false });
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/credentials")
|
||||
.then(r => r.json())
|
||||
.then(d => setCreds(d))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"relative flex flex-col border-r border-[#1e1e2e] bg-[#111118] transition-all duration-300",
|
||||
sidebarCollapsed ? "w-16" : "w-60"
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 px-4 py-5 border-b border-[#1e1e2e]">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
|
||||
<Zap className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="font-bold text-lg tracking-tight text-white">LeadFlow</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-2 py-4 space-y-1">
|
||||
{navItems.map(({ href, icon: Icon, label, color }) => {
|
||||
const active = pathname === href || pathname.startsWith(href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all",
|
||||
active
|
||||
? "bg-[#1e1e2e] text-white"
|
||||
: "text-gray-400 hover:text-white hover:bg-[#1a1a28]"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("w-5 h-5 flex-shrink-0", active ? color : "")} />
|
||||
{!sidebarCollapsed && <span>{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Credential status */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="px-4 py-4 border-t border-[#1e1e2e] space-y-2">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-3">API Status</p>
|
||||
{[
|
||||
{ key: "anymailfinder", label: "Anymailfinder" },
|
||||
{ key: "apify", label: "Apify" },
|
||||
{ key: "vayne", label: "Vayne" },
|
||||
].map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span className={cn("w-2 h-2 rounded-full flex-shrink-0", creds[key as keyof CredentialStatus] ? "bg-green-500" : "bg-red-500")} />
|
||||
<span className="text-xs text-gray-400">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="absolute -right-3 top-6 w-6 h-6 rounded-full bg-[#1e1e2e] border border-[#2e2e3e] flex items-center justify-center hover:bg-[#2e2e3e] transition-colors z-10"
|
||||
>
|
||||
{sidebarCollapsed ? <ChevronRight className="w-3 h-3 text-gray-400" /> : <ChevronLeft className="w-3 h-3 text-gray-400" />}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
36
components/layout/TopBar.tsx
Normal file
36
components/layout/TopBar.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAppStore } from "@/lib/store";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
const BREADCRUMBS: Record<string, string> = {
|
||||
"/airscale": "AirScale → Email",
|
||||
"/linkedin": "LinkedIn → Email",
|
||||
"/serp": "SERP → Email",
|
||||
"/results": "Results & History",
|
||||
"/settings": "Settings",
|
||||
};
|
||||
|
||||
export function TopBar() {
|
||||
const pathname = usePathname();
|
||||
const { activeJobs } = useAppStore();
|
||||
const runningJobs = activeJobs.filter(j => j.status === "running").length;
|
||||
const label = BREADCRUMBS[pathname] || "Dashboard";
|
||||
|
||||
return (
|
||||
<header className="h-14 border-b border-[#1e1e2e] bg-[#111118]/80 backdrop-blur flex items-center justify-between px-6 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-500">LeadFlow</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-white font-medium">{label}</span>
|
||||
</div>
|
||||
{runningJobs > 0 && (
|
||||
<div className="flex items-center gap-2 bg-blue-500/10 border border-blue-500/20 rounded-full px-3 py-1">
|
||||
<Activity className="w-3.5 h-3.5 text-blue-400 animate-pulse" />
|
||||
<span className="text-xs text-blue-400 font-medium">{runningJobs} Active Job{runningJobs > 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
21
components/shared/EmptyState.tsx
Normal file
21
components/shared/EmptyState.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-[#1e1e2e] flex items-center justify-center mb-4">
|
||||
<Icon className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-white mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-500 max-w-sm mb-6">{description}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
components/shared/ExportButtons.tsx
Normal file
38
components/shared/ExportButtons.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, FileSpreadsheet } from "lucide-react";
|
||||
import { exportToCSV, exportToExcel, type ExportRow } from "@/lib/utils/csv";
|
||||
|
||||
interface ExportButtonsProps {
|
||||
rows: ExportRow[];
|
||||
filename: string;
|
||||
disabled?: boolean;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export function ExportButtons({ rows, filename, disabled, summary }: ExportButtonsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{summary && <span className="text-sm text-gray-400">{summary}</span>}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled || rows.length === 0}
|
||||
onClick={() => exportToCSV(rows, `${filename}.csv`)}
|
||||
className="border-[#2e2e3e] hover:border-blue-500/50 hover:bg-blue-500/5 text-gray-300"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1.5" /> Download CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled || rows.length === 0}
|
||||
onClick={() => exportToExcel(rows, `${filename}.xlsx`)}
|
||||
className="border-[#2e2e3e] hover:border-purple-500/50 hover:bg-purple-500/5 text-gray-300"
|
||||
>
|
||||
<FileSpreadsheet className="w-4 h-4 mr-1.5" /> Download Excel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
components/shared/FileDropZone.tsx
Normal file
68
components/shared/FileDropZone.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Upload, FileText } from "lucide-react";
|
||||
|
||||
interface FileDropZoneProps {
|
||||
onFile: (content: string, filename: string) => void;
|
||||
accept?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function FileDropZone({ onFile, accept = ".csv", label = "Drop your CSV file here" }: FileDropZoneProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [filename, setFilename] = useState<string | null>(null);
|
||||
|
||||
const processFile = useCallback((file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
onFile(e.target?.result as string, file.name);
|
||||
setFilename(file.name);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}, [onFile]);
|
||||
|
||||
const onDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) processFile(file);
|
||||
}, [processFile]);
|
||||
|
||||
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) processFile(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center w-full h-40 border-2 border-dashed rounded-xl cursor-pointer transition-all",
|
||||
dragging
|
||||
? "border-blue-500 bg-blue-500/5 shadow-[0_0_20px_rgba(59,130,246,0.15)]"
|
||||
: filename
|
||||
? "border-green-500/50 bg-green-500/5"
|
||||
: "border-[#2e2e3e] bg-[#0d0d18] hover:border-blue-500/50 hover:bg-blue-500/5"
|
||||
)}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<input type="file" accept={accept} className="sr-only" onChange={onInputChange} />
|
||||
{filename ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileText className="w-8 h-8 text-green-400" />
|
||||
<span className="text-sm text-green-400 font-medium">{filename}</span>
|
||||
<span className="text-xs text-gray-500">Click to replace</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Upload className="w-8 h-8 text-gray-500" />
|
||||
<span className="text-sm text-gray-300">{label}</span>
|
||||
<span className="text-xs text-gray-500">Click to browse or drag & drop</span>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
64
components/shared/ProgressCard.tsx
Normal file
64
components/shared/ProgressCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProgressCardProps {
|
||||
title: string;
|
||||
current: number;
|
||||
total: number;
|
||||
subtitle?: string;
|
||||
status?: "running" | "complete" | "failed" | "idle";
|
||||
}
|
||||
|
||||
export function ProgressCard({ title, current, total, subtitle, status = "running" }: ProgressCardProps) {
|
||||
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="bg-[#111118] border border-[#1e1e2e] rounded-xl p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-white">{title}</h3>
|
||||
{subtitle && <p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>{current.toLocaleString()} / {total.toLocaleString()}</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-[#1e1e2e] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-500",
|
||||
status === "failed"
|
||||
? "bg-red-500"
|
||||
: status === "complete"
|
||||
? "bg-green-500"
|
||||
: "bg-gradient-to-r from-blue-500 to-purple-600 animate-pulse"
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const config: Record<string, { label: string; color: string; dot: string }> = {
|
||||
running: { label: "Running", color: "bg-blue-500/10 text-blue-400 border-blue-500/20", dot: "bg-blue-400 animate-pulse" },
|
||||
complete: { label: "Complete", color: "bg-green-500/10 text-green-400 border-green-500/20", dot: "bg-green-400" },
|
||||
failed: { label: "Failed", color: "bg-red-500/10 text-red-400 border-red-500/20", dot: "bg-red-400" },
|
||||
pending: { label: "Pending", color: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20", dot: "bg-yellow-400" },
|
||||
idle: { label: "Idle", color: "bg-gray-500/10 text-gray-400 border-gray-500/20", dot: "bg-gray-400" },
|
||||
};
|
||||
const c = config[status] || config.idle;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border ${c.color}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||
{c.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
145
components/shared/ResultsTable.tsx
Normal file
145
components/shared/ResultsTable.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckCircle2, XCircle, AlertCircle, ChevronUp, ChevronDown } from "lucide-react";
|
||||
|
||||
export interface ResultRow {
|
||||
id: string;
|
||||
companyName?: string;
|
||||
domain?: string;
|
||||
contactName?: string;
|
||||
contactTitle?: string;
|
||||
email?: string;
|
||||
confidence?: number;
|
||||
linkedinUrl?: string;
|
||||
status?: string;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
interface ResultsTableProps {
|
||||
rows: ResultRow[];
|
||||
loading?: boolean;
|
||||
selectable?: boolean;
|
||||
onSelectionChange?: (ids: string[]) => void;
|
||||
extraColumns?: Array<{ key: string; label: string }>;
|
||||
}
|
||||
|
||||
function EmailStatusIcon({ email, confidence }: { email?: string; confidence?: number }) {
|
||||
if (!email) return <XCircle className="w-4 h-4 text-red-400" />;
|
||||
if (confidence && confidence < 0.7) return <AlertCircle className="w-4 h-4 text-yellow-400" />;
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-400" />;
|
||||
}
|
||||
|
||||
export function ResultsTable({ rows, loading, selectable, onSelectionChange, extraColumns }: ResultsTableProps) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(null);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
const next = new Set(selected);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
setSelected(next);
|
||||
onSelectionChange?.(Array.from(next));
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selected.size === rows.length) {
|
||||
setSelected(new Set());
|
||||
onSelectionChange?.([]);
|
||||
} else {
|
||||
const all = new Set(rows.map(r => r.id));
|
||||
setSelected(all);
|
||||
onSelectionChange?.(Array.from(all));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full bg-[#1e1e2e]" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-[#1e1e2e]">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1e1e2e] bg-[#0d0d18]">
|
||||
{selectable && (
|
||||
<th className="w-10 px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.size === rows.length && rows.length > 0}
|
||||
onChange={toggleAll}
|
||||
className="rounded border-[#2e2e3e] bg-[#1e1e2e]"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Company</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Domain</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Contact</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Title</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Email</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Confidence</th>
|
||||
{extraColumns?.map(col => (
|
||||
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">{col.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, idx) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"border-b border-[#1e1e2e]/50 transition-colors",
|
||||
idx % 2 === 0 ? "bg-[#111118]" : "bg-[#0f0f1a]",
|
||||
selectable && "cursor-pointer hover:bg-[#1a1a28]",
|
||||
selectable && selected.has(row.id) && "bg-blue-500/5"
|
||||
)}
|
||||
onClick={() => selectable && toggleSelect(row.id)}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(row.id)}
|
||||
onChange={() => toggleSelect(row.id)}
|
||||
className="rounded border-[#2e2e3e] bg-[#1e1e2e]"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-2.5 text-white font-medium">{row.companyName || "—"}</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 font-mono text-xs">{row.domain || "—"}</td>
|
||||
<td className="px-4 py-2.5 text-gray-300">{row.contactName || "—"}</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 text-xs">{row.contactTitle || "—"}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EmailStatusIcon email={row.email} confidence={row.confidence} />
|
||||
<span className="text-gray-300 font-mono text-xs">{row.email || "—"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{row.confidence !== undefined ? (
|
||||
<span className={cn("text-xs font-medium", row.confidence >= 0.8 ? "text-green-400" : row.confidence >= 0.6 ? "text-yellow-400" : "text-red-400")}>
|
||||
{Math.round(row.confidence * 100)}%
|
||||
</span>
|
||||
) : "—"}
|
||||
</td>
|
||||
{extraColumns?.map(col => (
|
||||
<td key={col.key} className="px-4 py-2.5 text-gray-400 text-xs">
|
||||
{((row as unknown) as Record<string, string>)[col.key] || "—"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
components/shared/RoleChipsInput.tsx
Normal file
61
components/shared/RoleChipsInput.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState, KeyboardEvent } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RoleChipsInputProps {
|
||||
value: string[];
|
||||
onChange: (roles: string[]) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function RoleChipsInput({ value, onChange, label = "Target Roles" }: RoleChipsInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const add = (role: string) => {
|
||||
const trimmed = role.trim();
|
||||
if (trimmed && !value.includes(trimmed)) {
|
||||
onChange([...value, trimmed]);
|
||||
}
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const remove = (role: string) => onChange(value.filter(r => r !== role));
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
add(input);
|
||||
} else if (e.key === "Backspace" && !input && value.length > 0) {
|
||||
remove(value[value.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && <label className="block text-sm font-medium text-gray-300 mb-2">{label}</label>}
|
||||
<div className="min-h-[44px] flex flex-wrap gap-2 p-2.5 bg-[#0d0d18] border border-[#2e2e3e] rounded-lg focus-within:border-blue-500 transition-colors">
|
||||
{value.map(role => (
|
||||
<span
|
||||
key={role}
|
||||
className="flex items-center gap-1 bg-blue-500/20 text-blue-300 border border-blue-500/30 rounded-md px-2.5 py-0.5 text-sm"
|
||||
>
|
||||
{role}
|
||||
<button onClick={() => remove(role)} className="hover:text-white transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
className="flex-1 min-w-[120px] bg-transparent text-sm text-white outline-none placeholder:text-gray-600"
|
||||
placeholder="Add role, press Enter..."
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => input && add(input)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
components/ui/alert.tsx
Normal file
76
components/ui/alert.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
103
components/ui/card.tsx
Normal file
103
components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
29
components/ui/checkbox.tsx
Normal file
29
components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
157
components/ui/dialog.tsx
Normal file
157
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
20
components/ui/input.tsx
Normal file
20
components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
20
components/ui/label.tsx
Normal file
20
components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
201
components/ui/select.tsx
Normal file
201
components/ui/select.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *: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 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]: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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
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.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
49
components/ui/sonner.tsx
Normal file
49
components/ui/sonner.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
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
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal 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-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
Reference in New Issue
Block a user