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:
Timo Uttenweiler
2026-03-17 11:21:11 +01:00
parent 5b84001c1e
commit facf8c9f69
59 changed files with 5800 additions and 233 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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,
}

View 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
View 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
View 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
View 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
View 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,
}

View 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
View 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 }

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-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 }