Add TransferTribe app and fix critical transfer security flaws
The application source was untracked, so this commit brings it under version control together with fixes for the issues found while auditing it. Notable fixes: Authorization - Require a session and scope to senderEmail on /api/transfers/[id] (GET, DELETE) and .../resend. These were unauthenticated over an autoincrement id, so the ids could be walked to soft-delete any transfer, read sender/recipient metadata, or make the app mail arbitrary recipients. - Require a session on the legacy /api/send and /api/chunk-upload endpoints, and take the sender from the session rather than a request field so transfers cannot be posted as another user. Encryption - Replace the chunk encryption scheme. Every chunk was encrypted under one shared session IV with its auth tag discarded, which reuses the AES-GCM keystream (XORing two ciphertexts recovers plaintext without the key) and left the stored file undecryptable, surfacing to users as a wrong-password error. Chunks are now self-contained frames carrying their own random IV and auth tag, behind a magic+salt header. - Files written by the previous format now report UNSUPPORTED_FORMAT instead of a misleading password error. Download - Verify the password against the stored bcrypt hash before serving a file, and enforce expiresAt and DELETED/EXPIRED status. - Move the password from the query string into a POST body so it stays out of access logs and Referer headers. - Record a download only after successful authentication. - Decrypt frame by frame through a stream instead of buffering the whole file, and encode the Content-Disposition filename per RFC 5987. Data exposure - /api/download ran before the password prompt and returned the full transfer row, including absolute server file paths. It now returns only what the pre-password screen renders; filenames, message and recipient are withheld until /api/verify succeeds. Correctness - Fix BigInt handling that made /api/transfers and /api/transfers/[id] fail unconditionally (JSON.stringify cannot serialize BigInt, and seeding a BigInt reduce with 0 throws). - Fail loudly on a missing chunk during reassembly rather than silently writing a corrupt file. - Meter plan usage in plaintext bytes rather than on-disk encrypted size. Ignore /uploads: it holds runtime transfer payloads, not source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Moon, Sun, Menu, X, Upload } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useSession, signOut } from 'next-auth/react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
|
||||
export function Header() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { data: session, status } = useSession();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const handleSignOut = () => {
|
||||
signOut({ callbackUrl: '/' });
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm border-slate-200 dark:border-slate-800">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href={session ? "/send" : "/"} className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<Upload className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
Transfer Tribe
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="hidden md:flex items-center space-x-6">
|
||||
{session ? (
|
||||
<>
|
||||
<Link href="/send" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition-colors">
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link href="/pricing" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition-colors">
|
||||
Pricing
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
<Link href="/pricing" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition-colors">
|
||||
Pricing
|
||||
</Link>
|
||||
<a href="#features" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition-colors">
|
||||
Features
|
||||
</a>
|
||||
<a href="#about" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition-colors">
|
||||
About
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
className="w-9 h-9"
|
||||
>
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
|
||||
{status === 'loading' ? (
|
||||
<div className="w-8 h-8 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse" />
|
||||
) : session ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={session.user?.image || ''} alt={session.user?.name || ''} />
|
||||
<AvatarFallback>
|
||||
{session.user?.name?.charAt(0) || session.user?.email?.charAt(0) || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<div className="flex items-center justify-start gap-2 p-2">
|
||||
<div className="flex flex-col space-y-1 leading-none">
|
||||
{session.user?.name && (
|
||||
<p className="font-medium">{session.user.name}</p>
|
||||
)}
|
||||
{session.user?.email && (
|
||||
<p className="w-[200px] truncate text-sm text-muted-foreground">
|
||||
{session.user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/send">Dashboard</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings">Settings</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/auth/signin">
|
||||
<Button variant="outline" size="sm" className="hidden sm:flex">
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/auth/signup">
|
||||
<Button size="sm" className="hidden sm:flex bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700">
|
||||
Get Started
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
>
|
||||
{isMenuOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMenuOpen && (
|
||||
<div className="md:hidden py-4 border-t border-slate-200 dark:border-slate-800">
|
||||
<nav className="flex flex-col space-y-4">
|
||||
{session ? (
|
||||
<>
|
||||
<Link href="/send" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100">
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link href="/pricing" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100">
|
||||
Pricing
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100">
|
||||
Home
|
||||
</Link>
|
||||
<Link href="/pricing" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100">
|
||||
Pricing
|
||||
</Link>
|
||||
<a href="#features" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100">
|
||||
Features
|
||||
</a>
|
||||
<a href="#about" className="text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100">
|
||||
About
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col space-y-2 pt-4 border-t border-slate-200 dark:border-slate-800">
|
||||
{session ? (
|
||||
<>
|
||||
<div className="px-2 py-1">
|
||||
<p className="text-sm font-medium">{session.user?.name}</p>
|
||||
<p className="text-xs text-slate-500">{session.user?.email}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleSignOut}>
|
||||
Sign Out
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/auth/signin">
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/">
|
||||
<Button size="sm" className="w-full bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700">
|
||||
Get Started
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowRight, Shield, Zap, Globe, Mail, Lock, Clock } from 'lucide-react';
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<div className="text-center py-12 space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-4xl md:text-6xl font-bold bg-gradient-to-r from-slate-900 via-blue-600 to-indigo-600 dark:from-slate-100 dark:via-blue-400 dark:to-indigo-400 bg-clip-text text-transparent">
|
||||
Send Files Securely
|
||||
<span className="block">Via Email</span>
|
||||
</h1>
|
||||
<p className="text-xl text-slate-600 dark:text-slate-400 max-w-2xl mx-auto leading-relaxed">
|
||||
Share files of any size with password protection and 48-hour expiration.
|
||||
Recipients get secure download links via email - just like WeTransfer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Button size="lg" className="bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 text-white px-8 py-3 rounded-full shadow-lg hover:shadow-xl transition-all">
|
||||
Send Your First Transfer
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="px-8 py-3 rounded-full">
|
||||
Learn More
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto pt-12">
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-green-400 to-emerald-600 rounded-full flex items-center justify-center">
|
||||
<Mail className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Email Delivery</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 text-center">
|
||||
Recipients receive secure download links directly in their inbox
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-amber-400 to-orange-600 rounded-full flex items-center justify-center">
|
||||
<Lock className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Password Protected</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 text-center">
|
||||
Every transfer is secured with a unique password for safe access
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-red-400 to-pink-600 rounded-full flex items-center justify-center">
|
||||
<Clock className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">48-Hour Expiry</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 text-center">
|
||||
Transfers automatically expire after 48 hours for security
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Search,
|
||||
Download,
|
||||
Share2,
|
||||
Trash2,
|
||||
File,
|
||||
Mail,
|
||||
Clock,
|
||||
Users,
|
||||
MoreVertical,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { toast } from 'sonner';
|
||||
import { format } from 'date-fns';
|
||||
import { formatFileSize, formatTimeRemaining } from '@/lib/utils';
|
||||
|
||||
interface Transfer {
|
||||
id: number; // probably `Int` in Prisma
|
||||
senderEmail: string;
|
||||
recipientEmail: string;
|
||||
message?: string;
|
||||
files: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
}>;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
downloadCount: number;
|
||||
downloadUrl: string;
|
||||
status: 'ACTIVE' | 'EXPIRED' | 'DELETED';
|
||||
totalFiles: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
export function MyTransfers() {
|
||||
const { data: session } = useSession();
|
||||
const [transfers, setTransfers] = useState<Transfer[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const filteredTransfers = transfers.filter(
|
||||
(transfer) =>
|
||||
transfer.recipientEmail
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
transfer.files.some((file) =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
const fetchTransfers = async () => {
|
||||
if (!session?.user?.email) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/transfers');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTransfers(data.transfers);
|
||||
console.log('Transfers:', data.transfers);
|
||||
} else {
|
||||
toast.error('Failed to fetch transfers');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch transfers');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTransfers();
|
||||
}, [session]);
|
||||
|
||||
const handleResend = async (transferId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/transfers/${transferId}/resend`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Transfer resend successful!');
|
||||
} else {
|
||||
toast.error('Failed to resend transfer');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to resend transfer');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (transferId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/transfers/${transferId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTransfers((prev) => prev.filter((t) => t.id !== transferId));
|
||||
toast.success('Transfer deleted successfully');
|
||||
} else {
|
||||
toast.error('Failed to delete transfer');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete transfer');
|
||||
}
|
||||
};
|
||||
|
||||
const copyShareLink = (transferId: number) => {
|
||||
const transfer = transfers.find((t) => t.id === transferId);
|
||||
|
||||
if (!transfer?.downloadUrl) {
|
||||
toast.error('Download URL not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const shareUrl = `${window.location.origin}/download/${transfer.downloadUrl}`;
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
toast.success('Share link copied to clipboard!');
|
||||
};
|
||||
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'ACTIVE':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400';
|
||||
case 'EXPIRED':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400';
|
||||
case 'DELETED':
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between">
|
||||
<div className="flex gap-2 flex-1">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Search transfers..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={fetchTransfers}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{transfers.length === 0 && !isLoading && (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<File className="w-8 h-8 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
||||
No transfers found
|
||||
</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
You haven't sent any transfers yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredTransfers.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{filteredTransfers.map((transfer) => (
|
||||
<Card
|
||||
key={transfer.id}
|
||||
className="bg-white/60 dark:bg-slate-800/60 backdrop-blur-sm border-white/20 dark:border-slate-700/20 hover:bg-white/80 dark:hover:bg-slate-800/80 transition-all"
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<File className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
To: {transfer.recipientEmail}
|
||||
</h4>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{transfer.totalFiles} file
|
||||
{transfer.totalFiles !== 1 ? 's' : ''} •{' '}
|
||||
{formatFileSize(transfer.totalSize)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400 mb-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{format(new Date(transfer.createdAt), 'MMM dd, yyyy')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Download className="w-3 h-3" />
|
||||
{transfer.downloadCount} downloads
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{formatTimeRemaining(new Date(transfer.expiresAt))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge className={getStatusColor(transfer.status)}>
|
||||
{transfer.status.toLowerCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
{transfer.status === 'ACTIVE' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => copyShareLink(transfer.id)}
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Copy Link
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleResend(transfer.id)}
|
||||
>
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
Resend
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(transfer.id)}
|
||||
className="text-red-600 dark:text-red-400"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{transfer.message && (
|
||||
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
"{transfer.message}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
||||
Files
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{transfer.files.slice(0, 3).map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="bg-slate-100 dark:bg-slate-700 rounded-md px-2 py-1 text-xs text-slate-600 dark:text-slate-400"
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
))}
|
||||
{transfer.files.length > 3 && (
|
||||
<div className="bg-slate-100 dark:bg-slate-700 rounded-md px-2 py-1 text-xs text-slate-600 dark:text-slate-400">
|
||||
+{transfer.files.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredTransfers.length === 0 && searchTerm && transfers.length > 0 && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
No transfers found matching "{searchTerm}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { encryptBlob } from '@/lib/encryption';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Upload, File, X, Send } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { formatFileSize } from '@/lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const PLAN_LIMITS = {
|
||||
free: {
|
||||
maxFileSizeMB: 2048,
|
||||
maxMonthlyTransferSizeMB: 10240,
|
||||
expiryText: '48 hours after sending',
|
||||
},
|
||||
rookie: {
|
||||
maxFileSizeMB: Infinity,
|
||||
maxMonthlyTransferSizeMB: 30720,
|
||||
expiryText: '7 days after sending',
|
||||
},
|
||||
pro: {
|
||||
maxFileSizeMB: Infinity,
|
||||
maxMonthlyTransferSizeMB: 1024 * 1024,
|
||||
expiryText: '30 days after sending',
|
||||
},
|
||||
};
|
||||
|
||||
const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks
|
||||
|
||||
export default function SendPage() {
|
||||
const { data: session } = useSession();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [plan, setPlan] = useState<'free' | 'rookie' | 'pro'>('free');
|
||||
const [remainingMB, setRemainingMB] = useState(Infinity);
|
||||
const [usedMB, setUsedMB] = useState(0);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadStep, setUploadStep] = useState('');
|
||||
const [uploadMessage, setUploadMessage] = useState('');
|
||||
const [currentChunk, setCurrentChunk] = useState(0);
|
||||
const [totalChunks, setTotalChunks] = useState(0);
|
||||
const dropRef = useRef<HTMLDivElement>(null);
|
||||
const totalSize = files.reduce((acc, file) => acc + file.size, 0);
|
||||
const totalSizeMB = totalSize / 1024 / 1024;
|
||||
const isOverLimit =
|
||||
totalSizeMB > PLAN_LIMITS[plan].maxFileSizeMB || totalSizeMB > remainingMB;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlan = async () => {
|
||||
const res = await fetch('/api/usage');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPlan(data.plan);
|
||||
setRemainingMB(data.remainingMB);
|
||||
setUsedMB(data.usedMB);
|
||||
}
|
||||
};
|
||||
if (session?.user?.email) fetchPlan();
|
||||
}, [session]);
|
||||
|
||||
const handleFileAdd = (newFiles: File[]) => {
|
||||
const unique = newFiles.filter(
|
||||
(newFile) =>
|
||||
!files.find(
|
||||
(existing) =>
|
||||
existing.name === newFile.name && existing.size === newFile.size
|
||||
)
|
||||
);
|
||||
setFiles((prev) => [...prev, ...unique]);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files) return;
|
||||
handleFileAdd(Array.from(e.target.files));
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.files) {
|
||||
handleFileAdd(Array.from(e.dataTransfer.files));
|
||||
}
|
||||
dropRef.current?.classList.remove('border-blue-500');
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const triggerFileInput = () => fileInputRef.current?.click();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!files.length)
|
||||
return setUploadMessage('Please select at least one file');
|
||||
if (!recipient || !password)
|
||||
return setUploadMessage('All fields are required');
|
||||
if (isOverLimit)
|
||||
return setUploadMessage('File size exceeds your plan limits');
|
||||
const senderEmail = session?.user?.email;
|
||||
if (!senderEmail)
|
||||
return setUploadMessage('Unable to get sender email. Please log in.');
|
||||
|
||||
try {
|
||||
setUploadStep('Preparing files');
|
||||
const file = files[0]; // Upload first file for now
|
||||
|
||||
setUploadStep('Encrypting');
|
||||
// Encrypt the file using the password
|
||||
const { encryptedBlob } = await encryptBlob(file, password);
|
||||
|
||||
// Convert blob to Uint8Array once to avoid multiple arrayBuffer() calls
|
||||
// (which can cause "NotReadableError" on some browsers)
|
||||
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
||||
const iv = encryptedBytes.slice(0, 12);
|
||||
const ivBase64 = btoa(String.fromCharCode(...iv));
|
||||
|
||||
// Calculate chunks
|
||||
const CHUNK_SIZE_BYTES = CHUNK_SIZE;
|
||||
const totalChunksCount = Math.ceil(encryptedBytes.length / CHUNK_SIZE_BYTES);
|
||||
setTotalChunks(totalChunksCount);
|
||||
|
||||
// Generate upload ID
|
||||
const uploadId = crypto.randomUUID();
|
||||
|
||||
// Upload chunks
|
||||
setUploadStep('Uploading');
|
||||
for (let i = 0; i < totalChunksCount; i++) {
|
||||
setCurrentChunk(i + 1);
|
||||
|
||||
const start = i * CHUNK_SIZE_BYTES;
|
||||
const end = Math.min(start + CHUNK_SIZE_BYTES, encryptedBytes.length);
|
||||
const chunkBytes = encryptedBytes.slice(start, end);
|
||||
const chunkBlob = new Blob([chunkBytes]);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('chunk', chunkBlob);
|
||||
formData.append('uploadId', uploadId);
|
||||
formData.append('chunkIndex', i.toString());
|
||||
formData.append('totalChunks', totalChunksCount.toString());
|
||||
formData.append('chunkSize', CHUNK_SIZE_BYTES.toString());
|
||||
|
||||
// Only send metadata on first chunk
|
||||
if (i === 0) {
|
||||
formData.append('email', recipient);
|
||||
formData.append('password', password);
|
||||
formData.append('sender', senderEmail);
|
||||
formData.append('originalFilename', file.name);
|
||||
formData.append('encryptionIv', ivBase64);
|
||||
const filenames = files.map((f) => f.name);
|
||||
formData.append('filenames', JSON.stringify(filenames));
|
||||
formData.append('message', message);
|
||||
}
|
||||
|
||||
const uploadRes = await fetch('/api/chunk-upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
const error = await uploadRes.json();
|
||||
throw new Error(error.message || `Chunk ${i} upload failed`);
|
||||
}
|
||||
|
||||
// Update progress
|
||||
const chunkProgress = ((i + 1) / totalChunksCount) * 100;
|
||||
setUploadProgress(Math.round(chunkProgress));
|
||||
}
|
||||
|
||||
// Finalize upload
|
||||
setUploadStep('Finalizing');
|
||||
const finalRes = await fetch(`/api/chunk-upload?uploadId=${uploadId}`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
|
||||
if (!finalRes.ok) {
|
||||
const error = await finalRes.json();
|
||||
throw new Error(error.message || 'Failed to finalize upload');
|
||||
}
|
||||
|
||||
const result = await finalRes.json();
|
||||
|
||||
setUploadStep('');
|
||||
setUploadMessage('Success! 🎉');
|
||||
toast.success('Your transfer has been sent successfully!');
|
||||
setFiles([]);
|
||||
setRecipient('');
|
||||
setPassword('');
|
||||
setUploadProgress(100);
|
||||
setTimeout(() => setUploadMessage(''), 3000);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setUploadStep('');
|
||||
setUploadMessage('Error: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
function UploadStatusToast({
|
||||
step,
|
||||
progress,
|
||||
message,
|
||||
currentChunk,
|
||||
totalChunks,
|
||||
}: {
|
||||
step: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
currentChunk: number;
|
||||
totalChunks: number;
|
||||
}) {
|
||||
const showSpinner = ['Encrypting', 'Uploading', 'Finalizing'].includes(step);
|
||||
const isError =
|
||||
message.toLowerCase().includes('error') || message.includes('fail');
|
||||
|
||||
if (!step && !message) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full p-4 bg-slate-100 dark:bg-slate-800 rounded-lg shadow-md flex flex-col space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{showSpinner && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
)}
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
isError ? 'text-red-600' : 'text-slate-800 dark:text-slate-100'
|
||||
}`}
|
||||
>
|
||||
{step}{currentChunk && totalChunks ? ` (${currentChunk}/${totalChunks})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{!isError && step === 'Uploading' && (
|
||||
<>
|
||||
<Progress value={progress} className="h-2" />
|
||||
<p className="text-xs text-red-600 font-medium">
|
||||
⚠️ Please do not close this page during upload!
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* File Upload Section */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
|
||||
Upload Files
|
||||
</h3>
|
||||
<div
|
||||
ref={dropRef}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
dropRef.current?.classList.add('border-blue-500');
|
||||
}}
|
||||
onDragLeave={() =>
|
||||
dropRef.current?.classList.remove('border-blue-500')
|
||||
}
|
||||
className="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all"
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center">
|
||||
<Upload className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Drag & drop files here
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
or click to select files
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={triggerFileInput}
|
||||
>
|
||||
Select Files
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Files List */}
|
||||
{files.length > 0 && (
|
||||
<div className="mt-6 space-y-2">
|
||||
<h4 className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
Selected Files ({files.length})
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-900 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<File className="w-4 h-4 flex-shrink-0 text-slate-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(i)}
|
||||
className="p-1 hover:bg-slate-200 dark:hover:bg-slate-800 rounded"
|
||||
>
|
||||
<X className="w-4 h-4 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Size Info */}
|
||||
<div className="mt-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
<p>Total Size: {formatFileSize(totalSize)}</p>
|
||||
{remainingMB !== Infinity && (
|
||||
<p>Remaining: {remainingMB.toFixed(2)} MB</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transfer Details Section */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
|
||||
Transfer Details
|
||||
</h3>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="recipient" className="text-slate-700 dark:text-slate-300">
|
||||
Recipient Email
|
||||
</Label>
|
||||
<Input
|
||||
id="recipient"
|
||||
type="email"
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
placeholder="recipient@example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-slate-700 dark:text-slate-300">
|
||||
Encryption Password
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Create a strong password"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="message" className="text-slate-700 dark:text-slate-300">
|
||||
Message (optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Add a message to your recipient..."
|
||||
className="mt-1 resize-none"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOverLimit && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-red-700 dark:text-red-400 text-sm">
|
||||
⚠️ File size exceeds your plan limits
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!files.length || isOverLimit || uploadStep !== ''}
|
||||
className="w-full"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Send Transfer
|
||||
</Button>
|
||||
|
||||
{uploadMessage && (
|
||||
<UploadStatusToast
|
||||
step={uploadStep}
|
||||
progress={uploadProgress}
|
||||
message={uploadMessage}
|
||||
currentChunk={currentChunk}
|
||||
totalChunks={totalChunks}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Upload, File, X, Send } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { formatFileSize } from '@/lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const PLAN_LIMITS = {
|
||||
free: {
|
||||
maxFileSizeMB: 2048,
|
||||
maxMonthlyTransferSizeMB: 10240,
|
||||
expiryText: '48 hours after sending',
|
||||
},
|
||||
rookie: {
|
||||
maxFileSizeMB: Infinity,
|
||||
maxMonthlyTransferSizeMB: 30720,
|
||||
expiryText: '7 days after sending',
|
||||
},
|
||||
pro: {
|
||||
maxFileSizeMB: Infinity,
|
||||
maxMonthlyTransferSizeMB: 1024 * 1024,
|
||||
expiryText: '30 days after sending',
|
||||
},
|
||||
};
|
||||
|
||||
const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks
|
||||
|
||||
export default function SendPage() {
|
||||
const { data: session } = useSession();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [plan, setPlan] = useState<'free' | 'rookie' | 'pro'>('free');
|
||||
const [remainingMB, setRemainingMB] = useState(Infinity);
|
||||
const [usedMB, setUsedMB] = useState(0);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadStep, setUploadStep] = useState('');
|
||||
const [uploadMessage, setUploadMessage] = useState('');
|
||||
const [currentChunk, setCurrentChunk] = useState(0);
|
||||
const [totalChunks, setTotalChunks] = useState(0);
|
||||
const dropRef = useRef<HTMLDivElement>(null);
|
||||
const totalSize = files.reduce((acc, file) => acc + file.size, 0);
|
||||
const totalSizeMB = totalSize / 1024 / 1024;
|
||||
const isOverLimit =
|
||||
totalSizeMB > PLAN_LIMITS[plan].maxFileSizeMB || totalSizeMB > remainingMB;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlan = async () => {
|
||||
const res = await fetch('/api/usage');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPlan(data.plan);
|
||||
setRemainingMB(data.remainingMB);
|
||||
setUsedMB(data.usedMB);
|
||||
}
|
||||
};
|
||||
if (session?.user?.email) fetchPlan();
|
||||
}, [session]);
|
||||
|
||||
const handleFileAdd = (newFiles: File[]) => {
|
||||
const unique = newFiles.filter(
|
||||
(newFile) =>
|
||||
!files.find(
|
||||
(existing) =>
|
||||
existing.name === newFile.name && existing.size === newFile.size
|
||||
)
|
||||
);
|
||||
setFiles((prev) => [...prev, ...unique]);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files) return;
|
||||
handleFileAdd(Array.from(e.target.files));
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.files) {
|
||||
handleFileAdd(Array.from(e.dataTransfer.files));
|
||||
}
|
||||
dropRef.current?.classList.remove('border-blue-500');
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const triggerFileInput = () => fileInputRef.current?.click();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!files.length)
|
||||
return setUploadMessage('Please select at least one file');
|
||||
if (!recipient || !password)
|
||||
return setUploadMessage('All fields are required');
|
||||
if (isOverLimit)
|
||||
return setUploadMessage('File size exceeds your plan limits');
|
||||
const senderEmail = session?.user?.email;
|
||||
if (!senderEmail)
|
||||
return setUploadMessage('Unable to get sender email. Please log in.');
|
||||
|
||||
try {
|
||||
setUploadStep('Preparing files');
|
||||
const file = files[0]; // Upload first file for now
|
||||
|
||||
// Calculate chunks from raw file (no encryption)
|
||||
const totalChunksCount = Math.ceil(file.size / CHUNK_SIZE);
|
||||
setTotalChunks(totalChunksCount);
|
||||
|
||||
// Generate upload ID
|
||||
const uploadId = crypto.randomUUID();
|
||||
|
||||
// Upload raw chunks - server will encrypt each chunk
|
||||
setUploadStep('Uploading');
|
||||
for (let i = 0; i < totalChunksCount; i++) {
|
||||
setCurrentChunk(i + 1);
|
||||
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunkFile = file.slice(start, end);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('chunk', chunkFile);
|
||||
formData.append('uploadId', uploadId);
|
||||
formData.append('chunkIndex', i.toString());
|
||||
formData.append('totalChunks', totalChunksCount.toString());
|
||||
formData.append('chunkSize', CHUNK_SIZE.toString());
|
||||
|
||||
// Only send metadata on first chunk
|
||||
if (i === 0) {
|
||||
formData.append('email', recipient);
|
||||
formData.append('password', password);
|
||||
formData.append('sender', senderEmail);
|
||||
formData.append('originalFilename', file.name);
|
||||
const filenames = files.map((f) => f.name);
|
||||
formData.append('filenames', JSON.stringify(filenames));
|
||||
formData.append('message', message);
|
||||
}
|
||||
|
||||
const uploadRes = await fetch('/api/chunk-upload-v2', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
const error = await uploadRes.json();
|
||||
throw new Error(error.message || `Chunk ${i} upload failed`);
|
||||
}
|
||||
|
||||
// Update progress
|
||||
const chunkProgress = ((i + 1) / totalChunksCount) * 100;
|
||||
setUploadProgress(Math.round(chunkProgress));
|
||||
}
|
||||
|
||||
// Finalize upload
|
||||
setUploadStep('Finalizing');
|
||||
const finalRes = await fetch(`/api/chunk-upload-v2?uploadId=${uploadId}`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
|
||||
if (!finalRes.ok) {
|
||||
const error = await finalRes.json();
|
||||
throw new Error(error.message || 'Failed to finalize upload');
|
||||
}
|
||||
|
||||
const result = await finalRes.json();
|
||||
|
||||
setUploadStep('');
|
||||
setUploadMessage('Success! 🎉');
|
||||
toast.success('Your transfer has been sent successfully!');
|
||||
setFiles([]);
|
||||
setRecipient('');
|
||||
setPassword('');
|
||||
setUploadProgress(100);
|
||||
setTimeout(() => setUploadMessage(''), 3000);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setUploadStep('');
|
||||
setUploadMessage('Error: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
function UploadStatusToast({
|
||||
step,
|
||||
progress,
|
||||
message,
|
||||
currentChunk,
|
||||
totalChunks,
|
||||
}: {
|
||||
step: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
currentChunk: number;
|
||||
totalChunks: number;
|
||||
}) {
|
||||
const showSpinner = ['Uploading', 'Finalizing'].includes(step);
|
||||
const isError =
|
||||
message.toLowerCase().includes('error') || message.includes('fail');
|
||||
|
||||
if (!step && !message) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full p-4 bg-slate-100 dark:bg-slate-800 rounded-lg shadow-md flex flex-col space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{showSpinner && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
)}
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
isError ? 'text-red-600' : 'text-slate-800 dark:text-slate-100'
|
||||
}`}
|
||||
>
|
||||
{step}{currentChunk && totalChunks ? ` (${currentChunk}/${totalChunks})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{!isError && step === 'Uploading' && (
|
||||
<>
|
||||
<Progress value={progress} className="h-2" />
|
||||
<p className="text-xs text-red-600 font-medium">
|
||||
⚠️ Please do not close this page during upload!
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* File Upload Section */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
|
||||
Upload Files
|
||||
</h3>
|
||||
<div
|
||||
ref={dropRef}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
dropRef.current?.classList.add('border-blue-500');
|
||||
}}
|
||||
onDragLeave={() =>
|
||||
dropRef.current?.classList.remove('border-blue-500')
|
||||
}
|
||||
className="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all"
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center">
|
||||
<Upload className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Drag & drop files here
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
or click to select files
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={triggerFileInput}
|
||||
>
|
||||
Select Files
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Files List */}
|
||||
{files.length > 0 && (
|
||||
<div className="mt-6 space-y-2">
|
||||
<h4 className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
Selected Files ({files.length})
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-900 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<File className="w-4 h-4 flex-shrink-0 text-slate-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(i)}
|
||||
className="p-1 hover:bg-slate-200 dark:hover:bg-slate-800 rounded"
|
||||
>
|
||||
<X className="w-4 h-4 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Size Info */}
|
||||
<div className="mt-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
<p>Total Size: {formatFileSize(totalSize)}</p>
|
||||
{remainingMB !== Infinity && (
|
||||
<p>Remaining: {remainingMB.toFixed(2)} MB</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transfer Details Section */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
|
||||
Transfer Details
|
||||
</h3>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="recipient" className="text-slate-700 dark:text-slate-300">
|
||||
Recipient Email
|
||||
</Label>
|
||||
<Input
|
||||
id="recipient"
|
||||
type="email"
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
placeholder="recipient@example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-slate-700 dark:text-slate-300">
|
||||
Encryption Password
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Create a strong password"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="message" className="text-slate-700 dark:text-slate-300">
|
||||
Message (optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Add a message to your recipient..."
|
||||
className="mt-1 resize-none"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOverLimit && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-red-700 dark:text-red-400 text-sm">
|
||||
⚠️ File size exceeds your plan limits
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!files.length || isOverLimit || uploadStep !== ''}
|
||||
className="w-full"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Send Transfer
|
||||
</Button>
|
||||
|
||||
{uploadMessage && (
|
||||
<UploadStatusToast
|
||||
step={uploadStep}
|
||||
progress={uploadProgress}
|
||||
message={uploadMessage}
|
||||
currentChunk={currentChunk}
|
||||
totalChunks={totalChunks}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { encryptBlob } from '@/lib/encryption';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Upload, File, X, Send } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { formatFileSize } from '@/lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const PLAN_LIMITS = {
|
||||
free: {
|
||||
maxFileSizeMB: 2048,
|
||||
maxMonthlyTransferSizeMB: 10240,
|
||||
expiryText: '48 hours after sending',
|
||||
},
|
||||
rookie: {
|
||||
maxFileSizeMB: Infinity,
|
||||
maxMonthlyTransferSizeMB: 30720,
|
||||
expiryText: '7 days after sending',
|
||||
},
|
||||
pro: {
|
||||
maxFileSizeMB: Infinity,
|
||||
maxMonthlyTransferSizeMB: 1024 * 1024,
|
||||
expiryText: '30 days after sending',
|
||||
},
|
||||
};
|
||||
|
||||
export default function SendPage() {
|
||||
const { data: session } = useSession();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [plan, setPlan] = useState<'free' | 'rookie' | 'pro'>('free');
|
||||
const [remainingMB, setRemainingMB] = useState(Infinity);
|
||||
const [usedMB, setUsedMB] = useState(0);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadStep, setUploadStep] = useState('');
|
||||
const [uploadMessage, setUploadMessage] = useState('');
|
||||
const dropRef = useRef<HTMLDivElement>(null);
|
||||
const totalSize = files.reduce((acc, file) => acc + file.size, 0);
|
||||
const totalSizeMB = totalSize / 1024 / 1024;
|
||||
const isOverLimit =
|
||||
totalSizeMB > PLAN_LIMITS[plan].maxFileSizeMB || totalSizeMB > remainingMB;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlan = async () => {
|
||||
const res = await fetch('/api/usage');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPlan(data.plan);
|
||||
setRemainingMB(data.remainingMB);
|
||||
setUsedMB(data.usedMB);
|
||||
}
|
||||
};
|
||||
if (session?.user?.email) fetchPlan();
|
||||
}, [session]);
|
||||
|
||||
const handleFileAdd = (newFiles: File[]) => {
|
||||
const unique = newFiles.filter(
|
||||
(newFile) =>
|
||||
!files.find(
|
||||
(existing) =>
|
||||
existing.name === newFile.name && existing.size === newFile.size
|
||||
)
|
||||
);
|
||||
setFiles((prev) => [...prev, ...unique]);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files) return;
|
||||
handleFileAdd(Array.from(e.target.files));
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.files) {
|
||||
handleFileAdd(Array.from(e.dataTransfer.files));
|
||||
}
|
||||
dropRef.current?.classList.remove('border-blue-500');
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const triggerFileInput = () => fileInputRef.current?.click();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!files.length)
|
||||
return setUploadMessage('Please select at least one file');
|
||||
if (!recipient || !password)
|
||||
return setUploadMessage('All fields are required');
|
||||
if (isOverLimit)
|
||||
return setUploadMessage('File size exceeds your plan limits');
|
||||
const senderEmail = session?.user?.email;
|
||||
if (!senderEmail)
|
||||
return setUploadMessage('Unable to get sender email. Please log in.');
|
||||
|
||||
try {
|
||||
setUploadStep('Preparing files');
|
||||
const file = files[0]; // Upload first file for now
|
||||
|
||||
setUploadStep('Encrypting');
|
||||
// Encrypt the file using the password
|
||||
const { encryptedBlob } = await encryptBlob(file, password);
|
||||
|
||||
// Extract IV from encrypted blob (first 12 bytes)
|
||||
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
||||
const iv = encryptedBytes.slice(0, 12);
|
||||
const ivBase64 = btoa(String.fromCharCode(...iv));
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', encryptedBlob, file.name + '.enc');
|
||||
formData.append('email', recipient);
|
||||
formData.append('password', password);
|
||||
formData.append('sender', senderEmail);
|
||||
formData.append('originalFilename', file.name);
|
||||
formData.append('encryptionIv', ivBase64);
|
||||
const filenames = files.map((f) => f.name);
|
||||
formData.append('filenames', JSON.stringify(filenames));
|
||||
formData.append('message', message);
|
||||
|
||||
setUploadProgress(0);
|
||||
setUploadStep('Uploading');
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/send');
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
setUploadProgress(percent);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 200) {
|
||||
setUploadStep('');
|
||||
setUploadMessage('Success! 🎉');
|
||||
toast.success('Your transfer has been sent successfully!');
|
||||
setFiles([]);
|
||||
setRecipient('');
|
||||
setPassword('');
|
||||
setUploadProgress(100);
|
||||
setTimeout(() => setUploadMessage(''), 3000);
|
||||
} else {
|
||||
setUploadStep('');
|
||||
setUploadMessage(`Upload failed: ${xhr.statusText}`);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
setUploadStep('');
|
||||
setUploadMessage('Upload error occurred');
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setUploadStep('');
|
||||
setUploadMessage('Error: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
function UploadStatusToast({
|
||||
step,
|
||||
progress,
|
||||
message,
|
||||
}: {
|
||||
step: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
}) {
|
||||
const showSpinner = ['Zipping', 'Encrypting', 'Uploading'].includes(step);
|
||||
const isError =
|
||||
message.toLowerCase().includes('error') || message.includes('fail');
|
||||
|
||||
if (!step && !message) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full p-4 bg-slate-100 dark:bg-slate-800 rounded-lg shadow-md flex flex-col space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{showSpinner && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
)}
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
isError ? 'text-red-600' : 'text-slate-800 dark:text-slate-100'
|
||||
}`}
|
||||
>
|
||||
{step || message}
|
||||
</p>
|
||||
</div>
|
||||
{!isError && step === 'Uploading' && (
|
||||
<>
|
||||
<Progress value={progress} className="h-2" />
|
||||
<p className="text-xs text-red-600 font-medium">
|
||||
⚠️ Please do not close this page during upload!
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* File Upload Section */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
|
||||
Upload Files
|
||||
</h3>
|
||||
<div
|
||||
ref={dropRef}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
dropRef.current?.classList.add('border-blue-500');
|
||||
}}
|
||||
onDragLeave={() =>
|
||||
dropRef.current?.classList.remove('border-blue-500')
|
||||
}
|
||||
className="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all"
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center">
|
||||
<Upload className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Drag & drop files here
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
or click to select files (max 2GB)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={triggerFileInput}
|
||||
>
|
||||
Select Files
|
||||
</Button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="file-upload"
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
Files ({files.length})
|
||||
</h4>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
<span>Total: {formatFileSize(totalSize)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{files.map((file, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
className="bg-white/60 dark:bg-slate-800/60 backdrop-blur-sm border-white/20 dark:border-slate-700/20"
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<div className="w-10 h-10 bg-slate-100 dark:bg-slate-700 rounded-lg flex items-center justify-center">
|
||||
<File className="w-5 h-5 text-slate-600 dark:text-slate-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFile(idx)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<Progress
|
||||
value={
|
||||
plan === 'free'
|
||||
? Math.min(
|
||||
(totalSizeMB / PLAN_LIMITS.free.maxFileSizeMB) *
|
||||
100,
|
||||
100
|
||||
)
|
||||
: Math.min(
|
||||
((usedMB + totalSizeMB) /
|
||||
PLAN_LIMITS[plan].maxMonthlyTransferSizeMB) *
|
||||
100,
|
||||
100
|
||||
)
|
||||
}
|
||||
className={
|
||||
isOverLimit ? 'progress-error' : 'progress-success'
|
||||
}
|
||||
/>
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
isOverLimit
|
||||
? 'text-red-600 font-semibold'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
Plan: <strong>{plan}</strong> — Total:{' '}
|
||||
{totalSizeMB.toFixed(2)} MB /{' '}
|
||||
{plan === 'free'
|
||||
? `${PLAN_LIMITS.free.maxFileSizeMB} MB max per transfer`
|
||||
: `${remainingMB.toFixed(2)} MB remaining this month`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Transfer Details Section */}
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Transfer Details
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="recipientEmail">Recipient Email *</Label>
|
||||
<Input
|
||||
id="recipient"
|
||||
type="email"
|
||||
placeholder="recipient@example.com"
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">Message (Optional)</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Add a personal message..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">Password *</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter a secure password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
||||
Transfer Summary
|
||||
</h4>
|
||||
<div className="space-y-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
<p>Total Size: {formatFileSize(totalSize)}</p>
|
||||
<p>Expires: 48 hours after sending</p>
|
||||
<p>Security: Password protected</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-lg p-4">
|
||||
<UploadStatusToast
|
||||
step={uploadStep}
|
||||
progress={uploadProgress}
|
||||
message={uploadMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isOverLimit}
|
||||
className="w-full bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700"
|
||||
size="lg"
|
||||
>
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Send Transfer
|
||||
</>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Upload, Download, Users, Clock } from 'lucide-react';
|
||||
|
||||
export function Stats() {
|
||||
const [stats, setStats] = useState({
|
||||
totalFiles: 0,
|
||||
totalSize: 0,
|
||||
activeUsers: 0,
|
||||
uptime: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate real-time stats
|
||||
const interval = setInterval(() => {
|
||||
setStats(prev => ({
|
||||
totalFiles: prev.totalFiles + Math.floor(Math.random() * 3),
|
||||
totalSize: prev.totalSize + Math.floor(Math.random() * 50),
|
||||
activeUsers: 1247 + Math.floor(Math.random() * 100),
|
||||
uptime: 99.9
|
||||
}));
|
||||
}, 3000);
|
||||
|
||||
// Initial stats
|
||||
setStats({
|
||||
totalFiles: 15420,
|
||||
totalSize: 2847,
|
||||
activeUsers: 1247,
|
||||
uptime: 99.9
|
||||
});
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}TB`;
|
||||
return `${bytes}GB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card className="bg-white/60 dark:bg-slate-800/60 backdrop-blur-sm border-white/20 dark:border-slate-700/20 hover:bg-white/80 dark:hover:bg-slate-800/80 transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Upload className="h-4 w-4 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.totalFiles.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">Files Uploaded</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/60 dark:bg-slate-800/60 backdrop-blur-sm border-white/20 dark:border-slate-700/20 hover:bg-white/80 dark:hover:bg-slate-800/80 transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Download className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatFileSize(stats.totalSize)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">Data Transferred</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/60 dark:bg-slate-800/60 backdrop-blur-sm border-white/20 dark:border-slate-700/20 hover:bg-white/80 dark:hover:bg-slate-800/80 transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="h-4 w-4 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.activeUsers.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">Active Users</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/60 dark:bg-slate-800/60 backdrop-blur-sm border-white/20 dark:border-slate-700/20 hover:bg-white/80 dark:hover:bg-slate-800/80 transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.uptime}%
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">Uptime</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import type { ThemeProviderProps } from 'next-themes'
|
||||
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
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(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
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(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs 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",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -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(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 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-1.5 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-muted-foreground text-sm", 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,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
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(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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 p-1 shadow-md",
|
||||
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(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
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(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...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("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
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(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
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(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -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(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
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 }
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
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(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -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(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
Reference in New Issue
Block a user