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:
@@ -39,3 +39,8 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
# uploaded transfer payloads (runtime user data)
|
||||
/uploads
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -11,6 +11,9 @@ const compat = new FlatCompat({
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
ignores: ["src/generated/**", ".next/**"],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+1888
-29
File diff suppressed because it is too large
Load Diff
+38
-7
@@ -3,25 +3,56 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.10.0",
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"fflate": "^0.8.2",
|
||||
"formidable": "^3.5.4",
|
||||
"lucide-react": "^0.523.0",
|
||||
"next": "15.3.4",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-mailjet": "^6.0.8",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.3.4"
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/formidable": "^3.4.6",
|
||||
"@types/node": "^20.19.26",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.4",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"prisma": "^6.10.1",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
// pages/api/chunk-upload-v2.ts
|
||||
// Server-side encryption approach: Upload raw chunks, encrypt on server, store encrypted
|
||||
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { IncomingForm } from 'formidable';
|
||||
import fs from 'fs';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { UPLOAD_DIR } from '@/lib/config';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { PLAN_CONFIG } from '@/lib/subscription';
|
||||
import { sendMailjetEmail } from '@/lib/mailjet';
|
||||
import { deriveKeyFromPassword, encryptChunkFrame, buildHeader } from '@/lib/server-encryption';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
interface UploadSession {
|
||||
uploadId: string;
|
||||
totalSize: number;
|
||||
totalChunks: number;
|
||||
receivedChunks: Set<number>;
|
||||
chunkSize: number;
|
||||
sender: string;
|
||||
recipient: string;
|
||||
password?: string;
|
||||
originalFilename: string;
|
||||
message?: string;
|
||||
filenames?: string;
|
||||
createdAt: Date;
|
||||
// Encryption metadata. There is deliberately no session-wide IV: each chunk
|
||||
// frame carries its own, generated at encryption time.
|
||||
salt: Buffer;
|
||||
encryptionKey: Buffer;
|
||||
receivedBytes: number;
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
// Store active upload sessions
|
||||
const uploadSessions = new Map<string, UploadSession>();
|
||||
|
||||
// Cleanup abandoned uploads every 5 minutes
|
||||
const SESSION_TIMEOUT = 24 * 60 * 60 * 1000; // 24 hours
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [uploadId, session] of uploadSessions.entries()) {
|
||||
if (now - session.createdAt.getTime() > SESSION_TIMEOUT) {
|
||||
uploadSessions.delete(uploadId);
|
||||
const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId);
|
||||
fsPromises.rm(chunkDir, { recursive: true }).catch(err =>
|
||||
console.warn(`Failed to clean abandoned upload ${uploadId}:`, err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const authSession = await getServerSession(req, res, authOptions);
|
||||
if (!authSession?.user?.email) {
|
||||
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
}
|
||||
const sessionEmail = authSession.user.email;
|
||||
|
||||
if (req.method === 'POST') {
|
||||
return handleChunkUpload(req, res, sessionEmail);
|
||||
} else if (req.method === 'GET') {
|
||||
return handleStatusCheck(req, res, sessionEmail);
|
||||
} else if (req.method === 'PUT') {
|
||||
return handleChunkComplete(req, res, sessionEmail);
|
||||
}
|
||||
|
||||
return res.status(405).json({ success: false, message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
async function handleChunkUpload(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
sessionEmail: string
|
||||
) {
|
||||
const form = new IncomingForm({
|
||||
uploadDir: path.join(UPLOAD_DIR, '.tmp'),
|
||||
keepExtensions: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await fsPromises.mkdir(path.join(UPLOAD_DIR, '.tmp'), { recursive: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to create temp directory:', err);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
form.parse(req, async (err: Error | null, fields: any, files: any) => {
|
||||
if (err) {
|
||||
return resolve(res.status(400).json({ success: false, message: 'Parse error: ' + err.message }));
|
||||
}
|
||||
|
||||
try {
|
||||
const chunkFile = Array.isArray(files.chunk) ? files.chunk[0] : files.chunk;
|
||||
const uploadId = Array.isArray(fields.uploadId) ? fields.uploadId[0] : fields.uploadId;
|
||||
const chunkIndex = parseInt(Array.isArray(fields.chunkIndex) ? fields.chunkIndex[0] : fields.chunkIndex);
|
||||
const totalChunks = parseInt(Array.isArray(fields.totalChunks) ? fields.totalChunks[0] : fields.totalChunks);
|
||||
const chunkSize = parseInt(Array.isArray(fields.chunkSize) ? fields.chunkSize[0] : fields.chunkSize);
|
||||
|
||||
if (!chunkFile || !uploadId || isNaN(chunkIndex) || isNaN(totalChunks)) {
|
||||
return resolve(res.status(400).json({ success: false, message: 'Missing chunk metadata' }));
|
||||
}
|
||||
|
||||
// Get or create upload session
|
||||
let session = uploadSessions.get(uploadId);
|
||||
if (session && session.sender !== sessionEmail) {
|
||||
return resolve(res.status(403).json({ success: false, message: 'Forbidden' }));
|
||||
}
|
||||
if (!session) {
|
||||
const sender = sessionEmail; // never trust a sender field from the body
|
||||
const recipient = Array.isArray(fields.email) ? fields.email[0] : fields.email;
|
||||
const password = Array.isArray(fields.password) ? fields.password[0] : fields.password;
|
||||
const originalFilename = Array.isArray(fields.originalFilename) ? fields.originalFilename[0] : fields.originalFilename;
|
||||
const message = Array.isArray(fields.message) ? fields.message[0] : fields.message;
|
||||
const filenames = Array.isArray(fields.filenames) ? fields.filenames[0] : fields.filenames;
|
||||
|
||||
if (!recipient) {
|
||||
return resolve(res.status(400).json({ success: false, message: 'Missing required fields' }));
|
||||
}
|
||||
|
||||
// A password is mandatory: it is the key material. Without it every
|
||||
// file would be encrypted under a key derived from the empty string.
|
||||
if (!password) {
|
||||
return resolve(res.status(400).json({ success: false, message: 'A password is required' }));
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
const user = await prisma.user.findUnique({ where: { email: sender } });
|
||||
if (!user) {
|
||||
return resolve(res.status(404).json({ success: false, message: 'User not found' }));
|
||||
}
|
||||
|
||||
// Generate salt and derive encryption key
|
||||
const salt = crypto.randomBytes(16);
|
||||
const encryptionKey = await deriveKeyFromPassword(password, salt);
|
||||
|
||||
const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro';
|
||||
const limits = PLAN_CONFIG[plan];
|
||||
|
||||
session = {
|
||||
uploadId,
|
||||
totalSize: chunkSize * totalChunks,
|
||||
totalChunks,
|
||||
receivedChunks: new Set(),
|
||||
chunkSize,
|
||||
sender,
|
||||
recipient,
|
||||
password,
|
||||
originalFilename,
|
||||
message,
|
||||
filenames,
|
||||
createdAt: new Date(),
|
||||
salt,
|
||||
encryptionKey,
|
||||
receivedBytes: 0,
|
||||
maxBytes: limits.maxFileSize,
|
||||
};
|
||||
|
||||
// The advertised size is client-supplied, so this is only an early
|
||||
// reject; the real enforcement is the running receivedBytes check.
|
||||
if (limits.maxFileSize !== Infinity && session.totalSize > limits.maxFileSize) {
|
||||
return resolve(res.status(413).json({
|
||||
success: false,
|
||||
message: `File exceeds max size for your plan (${plan})`
|
||||
}));
|
||||
}
|
||||
|
||||
uploadSessions.set(uploadId, session);
|
||||
}
|
||||
|
||||
// Read and encrypt chunk into a self-contained frame (own IV + own tag)
|
||||
const chunkData = await fsPromises.readFile(chunkFile.filepath);
|
||||
|
||||
if (
|
||||
session.maxBytes !== Infinity &&
|
||||
session.receivedBytes + chunkData.length > session.maxBytes
|
||||
) {
|
||||
await fsPromises.unlink(chunkFile.filepath).catch(() => {});
|
||||
return resolve(res.status(413).json({
|
||||
success: false,
|
||||
message: 'File exceeds max size for your plan',
|
||||
}));
|
||||
}
|
||||
|
||||
const frame = encryptChunkFrame(chunkData, session.encryptionKey);
|
||||
|
||||
// Save encrypted chunk to temp location
|
||||
const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId);
|
||||
await fsPromises.mkdir(chunkDir, { recursive: true });
|
||||
const encryptedChunkPath = path.join(chunkDir, `chunk-${chunkIndex}.enc`);
|
||||
|
||||
await fsPromises.writeFile(encryptedChunkPath, frame);
|
||||
|
||||
// Clean up formidable temp file
|
||||
try {
|
||||
await fsPromises.unlink(chunkFile.filepath);
|
||||
} catch (err) {
|
||||
console.warn('Failed to clean up temp file:', err);
|
||||
}
|
||||
|
||||
// Guard against a retried chunk double-counting toward the quota.
|
||||
if (!session.receivedChunks.has(chunkIndex)) {
|
||||
session.receivedBytes += chunkData.length;
|
||||
}
|
||||
session.receivedChunks.add(chunkIndex);
|
||||
|
||||
return resolve(res.status(200).json({
|
||||
success: true,
|
||||
uploadId,
|
||||
chunkIndex,
|
||||
receivedChunks: Array.from(session.receivedChunks),
|
||||
}));
|
||||
} catch (err: any) {
|
||||
console.error('Chunk upload error:', err);
|
||||
return resolve(res.status(500).json({ success: false, message: 'Upload error: ' + err.message }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStatusCheck(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
sessionEmail: string
|
||||
) {
|
||||
const { uploadId } = req.query;
|
||||
|
||||
if (!uploadId || typeof uploadId !== 'string') {
|
||||
return res.status(400).json({ success: false, message: 'Missing uploadId' });
|
||||
}
|
||||
|
||||
const session = uploadSessions.get(uploadId);
|
||||
if (!session || session.sender !== sessionEmail) {
|
||||
return res.status(404).json({ success: false, message: 'Upload session not found' });
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
uploadId,
|
||||
totalChunks: session.totalChunks,
|
||||
receivedChunks: Array.from(session.receivedChunks),
|
||||
isComplete: session.receivedChunks.size === session.totalChunks,
|
||||
receivedBytes: session.receivedBytes,
|
||||
totalBytes: session.totalSize,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleChunkComplete(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
sessionEmail: string
|
||||
) {
|
||||
const { uploadId } = req.query;
|
||||
|
||||
if (!uploadId || typeof uploadId !== 'string') {
|
||||
return res.status(400).json({ success: false, message: 'Missing uploadId' });
|
||||
}
|
||||
|
||||
const session = uploadSessions.get(uploadId);
|
||||
if (!session || session.sender !== sessionEmail) {
|
||||
return res.status(404).json({ success: false, message: 'Upload session not found' });
|
||||
}
|
||||
|
||||
// Check if all chunks received
|
||||
if (session.receivedChunks.size !== session.totalChunks) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Missing chunks. Received ${session.receivedChunks.size}/${session.totalChunks}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Reassemble encrypted chunks into final encrypted file
|
||||
console.log(`[${uploadId}] Starting reassembly of ${session.totalChunks} encrypted chunks`);
|
||||
|
||||
const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId);
|
||||
const finalFilename = uuidv4() + '.enc';
|
||||
const finalPath = path.join(UPLOAD_DIR, finalFilename);
|
||||
|
||||
const writeStream = fs.createWriteStream(finalPath);
|
||||
|
||||
// Write the format header: magic (8) + salt (16). The per-chunk IVs live
|
||||
// in the frames themselves, so nothing needs storing in the DB.
|
||||
writeStream.write(buildHeader(session.salt));
|
||||
|
||||
// Assemble encrypted frames in order, with progress tracking
|
||||
let chunksAssembled = 0;
|
||||
for (let i = 0; i < session.totalChunks; i++) {
|
||||
const chunkPath = path.join(chunkDir, `chunk-${i}.enc`);
|
||||
|
||||
// Retry reading with exponential backoff
|
||||
let chunkData: Buffer | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
chunkData = await fsPromises.readFile(chunkPath);
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (attempt < 2 && err.code === 'EACCES') {
|
||||
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A silently skipped chunk would corrupt the file, so fail loudly.
|
||||
if (!chunkData) {
|
||||
throw new Error(`Chunk ${i} missing during reassembly`);
|
||||
}
|
||||
|
||||
writeStream.write(chunkData);
|
||||
chunksAssembled++;
|
||||
|
||||
if (chunksAssembled % 10 === 0 || chunksAssembled === session.totalChunks) {
|
||||
console.log(`[${uploadId}] Reassembly progress: ${chunksAssembled}/${session.totalChunks} chunks`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[${uploadId}] Waiting for write stream to finish...`);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.on('finish', resolve);
|
||||
writeStream.on('error', reject);
|
||||
writeStream.end();
|
||||
});
|
||||
|
||||
console.log(`[${uploadId}] Reassembly complete, validating file...`);
|
||||
|
||||
// Sizes recorded and metered are PLAINTEXT bytes actually received, not the
|
||||
// on-disk size, which is inflated by the header and per-frame IVs and tags.
|
||||
const plaintextSize = session.receivedBytes;
|
||||
|
||||
// Verify user and check monthly limits
|
||||
const user = await prisma.user.findUnique({ where: { email: session.sender } });
|
||||
if (!user) {
|
||||
await fsPromises.unlink(finalPath);
|
||||
return res.status(404).json({ success: false, message: 'User not found' });
|
||||
}
|
||||
|
||||
const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro';
|
||||
const limits = PLAN_CONFIG[plan];
|
||||
|
||||
const startOfMonth = new Date();
|
||||
startOfMonth.setDate(1);
|
||||
startOfMonth.setHours(0, 0, 0, 0);
|
||||
|
||||
const transfersThisMonth = await prisma.transfer.findMany({
|
||||
where: {
|
||||
senderEmail: session.sender,
|
||||
createdAt: { gte: startOfMonth },
|
||||
},
|
||||
});
|
||||
|
||||
const totalSizeSentThisMonth = transfersThisMonth.reduce((acc, t) => acc + BigInt(t.totalSize), BigInt(0));
|
||||
if (
|
||||
(limits.maxTransfersPerMonth !== Infinity && transfersThisMonth.length >= limits.maxTransfersPerMonth) ||
|
||||
(limits.maxTransferSizePerMonth !== Infinity && totalSizeSentThisMonth + BigInt(plaintextSize) > BigInt(limits.maxTransferSizePerMonth))
|
||||
) {
|
||||
await fsPromises.unlink(finalPath);
|
||||
return res.status(429).json({ success: false, message: 'Plan limits exceeded' });
|
||||
}
|
||||
|
||||
// Hash password for verification (don't encrypt again)
|
||||
const hash = session.password ? await bcrypt.hash(session.password, 10) : null;
|
||||
|
||||
// Parse filenames
|
||||
let totalFiles = 1;
|
||||
if (session.filenames) {
|
||||
try {
|
||||
const filesArray = JSON.parse(session.filenames);
|
||||
if (Array.isArray(filesArray)) {
|
||||
totalFiles = filesArray.length;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + limits.maxExpiryMs);
|
||||
|
||||
// Create transfer record
|
||||
const transfer = await prisma.transfer.create({
|
||||
data: {
|
||||
senderEmail: session.sender,
|
||||
recipientEmail: session.recipient,
|
||||
passwordHash: hash,
|
||||
downloadUrl: uuidv4(),
|
||||
expiresAt,
|
||||
filenames: session.filenames || null,
|
||||
message: session.message || null,
|
||||
totalFiles,
|
||||
totalSize: plaintextSize,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create file record with encryption metadata
|
||||
// Store salt and IV in the file path (prepended to encrypted data)
|
||||
// So we don't need separate DB fields
|
||||
await prisma.file.create({
|
||||
data: {
|
||||
name: session.originalFilename || 'file',
|
||||
path: finalPath,
|
||||
size: plaintextSize,
|
||||
transferId: transfer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Send email
|
||||
const link = `${process.env.NEXT_PUBLIC_APP_URL}/download/${transfer.downloadUrl}`;
|
||||
|
||||
await sendMailjetEmail({
|
||||
to: session.recipient,
|
||||
subject: 'You\'ve received an encrypted file',
|
||||
text: `
|
||||
${session.sender} sent you an encrypted file via TransferTribe.
|
||||
|
||||
Link: ${link}
|
||||
|
||||
${session.message ? `Message:\n${session.message}\n\n` : ''}Note: You'll need the password they shared with you to decrypt it.
|
||||
`.trim(),
|
||||
});
|
||||
|
||||
// Clean up session and temp files
|
||||
uploadSessions.delete(uploadId);
|
||||
try {
|
||||
await fsPromises.rm(chunkDir, { recursive: true });
|
||||
} catch (err) {
|
||||
console.warn('Failed to clean up temp directory:', err);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Upload complete',
|
||||
downloadUrl: transfer.downloadUrl,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('Chunk completion error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Completion error: ' + err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
// pages/api/chunk-upload.ts
|
||||
// Chunked upload endpoint for large files with encryption
|
||||
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { IncomingForm } from 'formidable';
|
||||
import fs from 'fs';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { UPLOAD_DIR } from '@/lib/config';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { PLAN_CONFIG } from '@/lib/subscription';
|
||||
import { sendMailjetEmail } from '@/lib/mailjet';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
interface ChunkMetadata {
|
||||
uploadId: string;
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
chunkSize: number;
|
||||
}
|
||||
|
||||
interface UploadSession {
|
||||
uploadId: string;
|
||||
totalSize: number;
|
||||
totalChunks: number;
|
||||
receivedChunks: Set<number>;
|
||||
chunkSize: number;
|
||||
sender: string;
|
||||
recipient: string;
|
||||
password?: string;
|
||||
encryptionIv: string;
|
||||
originalFilename: string;
|
||||
message?: string;
|
||||
filenames?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Store active upload sessions in memory (in production, use Redis)
|
||||
const uploadSessions = new Map<string, UploadSession>();
|
||||
|
||||
// Cleanup abandoned uploads every 5 minutes (default 24h timeout)
|
||||
const SESSION_TIMEOUT = 24 * 60 * 60 * 1000; // 24 hours
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [uploadId, session] of uploadSessions.entries()) {
|
||||
if (now - session.createdAt.getTime() > SESSION_TIMEOUT) {
|
||||
uploadSessions.delete(uploadId);
|
||||
// Clean up temp files
|
||||
const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId);
|
||||
fsPromises.rm(chunkDir, { recursive: true }).catch(err =>
|
||||
console.warn(`Failed to clean abandoned upload ${uploadId}:`, err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000); // Run every 5 minutes
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
}
|
||||
const sessionEmail = session.user.email;
|
||||
|
||||
if (req.method === 'POST') {
|
||||
return handleChunkUpload(req, res, sessionEmail);
|
||||
} else if (req.method === 'GET') {
|
||||
return handleStatusCheck(req, res, sessionEmail);
|
||||
} else if (req.method === 'PUT') {
|
||||
return handleChunkComplete(req, res, sessionEmail);
|
||||
}
|
||||
|
||||
return res.status(405).json({ success: false, message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
async function handleChunkUpload(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
sessionEmail: string
|
||||
) {
|
||||
const form = new IncomingForm({
|
||||
uploadDir: path.join(UPLOAD_DIR, '.tmp'),
|
||||
keepExtensions: true,
|
||||
});
|
||||
|
||||
// Ensure temp directory exists
|
||||
try {
|
||||
await fsPromises.mkdir(path.join(UPLOAD_DIR, '.tmp'), { recursive: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to create temp directory:', err);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
form.parse(req, async (err: Error | null, fields: any, files: any) => {
|
||||
if (err) {
|
||||
return resolve(res.status(400).json({ success: false, message: 'Parse error: ' + err.message }));
|
||||
}
|
||||
|
||||
try {
|
||||
const chunkFile = Array.isArray(files.chunk) ? files.chunk[0] : files.chunk;
|
||||
const uploadId = Array.isArray(fields.uploadId) ? fields.uploadId[0] : fields.uploadId;
|
||||
const chunkIndex = parseInt(Array.isArray(fields.chunkIndex) ? fields.chunkIndex[0] : fields.chunkIndex);
|
||||
const totalChunks = parseInt(Array.isArray(fields.totalChunks) ? fields.totalChunks[0] : fields.totalChunks);
|
||||
const chunkSize = parseInt(Array.isArray(fields.chunkSize) ? fields.chunkSize[0] : fields.chunkSize);
|
||||
|
||||
if (!chunkFile || !uploadId || isNaN(chunkIndex) || isNaN(totalChunks)) {
|
||||
return resolve(res.status(400).json({ success: false, message: 'Missing chunk metadata' }));
|
||||
}
|
||||
|
||||
// Get or create upload session
|
||||
let session = uploadSessions.get(uploadId);
|
||||
if (session && session.sender !== sessionEmail) {
|
||||
return resolve(res.status(403).json({ success: false, message: 'Forbidden' }));
|
||||
}
|
||||
if (!session) {
|
||||
const sender = sessionEmail;
|
||||
const recipient = Array.isArray(fields.email) ? fields.email[0] : fields.email;
|
||||
const password = Array.isArray(fields.password) ? fields.password[0] : fields.password;
|
||||
const encryptionIv = Array.isArray(fields.encryptionIv) ? fields.encryptionIv[0] : fields.encryptionIv;
|
||||
const originalFilename = Array.isArray(fields.originalFilename) ? fields.originalFilename[0] : fields.originalFilename;
|
||||
const message = Array.isArray(fields.message) ? fields.message[0] : fields.message;
|
||||
const filenames = Array.isArray(fields.filenames) ? fields.filenames[0] : fields.filenames;
|
||||
|
||||
if (!sender || !recipient || !encryptionIv) {
|
||||
return resolve(res.status(400).json({ success: false, message: 'Missing required fields' }));
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
const user = await prisma.user.findUnique({ where: { email: sender } });
|
||||
if (!user) {
|
||||
return resolve(res.status(404).json({ success: false, message: 'User not found' }));
|
||||
}
|
||||
|
||||
session = {
|
||||
uploadId,
|
||||
totalSize: chunkSize * totalChunks,
|
||||
totalChunks,
|
||||
receivedChunks: new Set(),
|
||||
chunkSize,
|
||||
sender,
|
||||
recipient,
|
||||
password,
|
||||
encryptionIv,
|
||||
originalFilename,
|
||||
message,
|
||||
filenames,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// Check plan limits before accepting upload
|
||||
const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro';
|
||||
const limits = PLAN_CONFIG[plan];
|
||||
|
||||
if (limits.maxFileSize !== Infinity && session.totalSize > limits.maxFileSize) {
|
||||
return resolve(res.status(413).json({
|
||||
success: false,
|
||||
message: `File exceeds max size for your plan (${plan})`
|
||||
}));
|
||||
}
|
||||
|
||||
uploadSessions.set(uploadId, session);
|
||||
}
|
||||
|
||||
// Save chunk to temp location
|
||||
const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId);
|
||||
await fsPromises.mkdir(chunkDir, { recursive: true });
|
||||
const chunkPath = path.join(chunkDir, `chunk-${chunkIndex}`);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const readStream = fs.createReadStream(chunkFile.filepath);
|
||||
const writeStream = fs.createWriteStream(chunkPath);
|
||||
|
||||
readStream.on('error', reject);
|
||||
writeStream.on('error', reject);
|
||||
writeStream.on('finish', resolve);
|
||||
|
||||
readStream.pipe(writeStream);
|
||||
});
|
||||
|
||||
// Clean up formidable temp file
|
||||
try {
|
||||
await fsPromises.unlink(chunkFile.filepath);
|
||||
} catch (err) {
|
||||
console.warn('Failed to clean up temp file:', err);
|
||||
}
|
||||
|
||||
session.receivedChunks.add(chunkIndex);
|
||||
|
||||
return resolve(res.status(200).json({
|
||||
success: true,
|
||||
uploadId,
|
||||
chunkIndex,
|
||||
receivedChunks: Array.from(session.receivedChunks),
|
||||
}));
|
||||
} catch (err: any) {
|
||||
console.error('Chunk upload error:', err);
|
||||
return resolve(res.status(500).json({ success: false, message: 'Upload error: ' + err.message }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStatusCheck(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
sessionEmail: string
|
||||
) {
|
||||
const { uploadId } = req.query;
|
||||
|
||||
if (!uploadId || typeof uploadId !== 'string') {
|
||||
return res.status(400).json({ success: false, message: 'Missing uploadId' });
|
||||
}
|
||||
|
||||
const session = uploadSessions.get(uploadId);
|
||||
if (!session || session.sender !== sessionEmail) {
|
||||
return res.status(404).json({ success: false, message: 'Upload session not found' });
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
uploadId,
|
||||
totalChunks: session.totalChunks,
|
||||
receivedChunks: Array.from(session.receivedChunks),
|
||||
isComplete: session.receivedChunks.size === session.totalChunks,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleChunkComplete(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
sessionEmail: string
|
||||
) {
|
||||
const { uploadId } = req.query;
|
||||
|
||||
if (!uploadId || typeof uploadId !== 'string') {
|
||||
return res.status(400).json({ success: false, message: 'Missing uploadId' });
|
||||
}
|
||||
|
||||
const session = uploadSessions.get(uploadId);
|
||||
if (!session || session.sender !== sessionEmail) {
|
||||
return res.status(404).json({ success: false, message: 'Upload session not found' });
|
||||
}
|
||||
|
||||
// Check if all chunks received
|
||||
if (session.receivedChunks.size !== session.totalChunks) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Missing chunks. Received ${session.receivedChunks.size}/${session.totalChunks}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Reassemble chunks into final file
|
||||
const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId);
|
||||
const finalFilename = uuidv4() + '.enc';
|
||||
const finalPath = path.join(UPLOAD_DIR, finalFilename);
|
||||
|
||||
const writeStream = fs.createWriteStream(finalPath);
|
||||
|
||||
for (let i = 0; i < session.totalChunks; i++) {
|
||||
const chunkPath = path.join(chunkDir, `chunk-${i}`);
|
||||
// Retry reading with exponential backoff to handle file locks
|
||||
let chunkData: Buffer | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
chunkData = await fsPromises.readFile(chunkPath);
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (attempt < 2 && err.code === 'EACCES') {
|
||||
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chunkData) {
|
||||
writeStream.write(chunkData);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.on('finish', resolve);
|
||||
writeStream.on('error', reject);
|
||||
writeStream.end();
|
||||
});
|
||||
|
||||
// Get final file size
|
||||
const stats = await fsPromises.stat(finalPath);
|
||||
|
||||
// Verify user and check monthly limits
|
||||
const user = await prisma.user.findUnique({ where: { email: session.sender } });
|
||||
if (!user) {
|
||||
await fsPromises.unlink(finalPath);
|
||||
return res.status(404).json({ success: false, message: 'User not found' });
|
||||
}
|
||||
|
||||
const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro';
|
||||
const limits = PLAN_CONFIG[plan];
|
||||
|
||||
const startOfMonth = new Date();
|
||||
startOfMonth.setDate(1);
|
||||
startOfMonth.setHours(0, 0, 0, 0);
|
||||
|
||||
const transfersThisMonth = await prisma.transfer.findMany({
|
||||
where: {
|
||||
senderEmail: session.sender,
|
||||
createdAt: { gte: startOfMonth },
|
||||
},
|
||||
});
|
||||
|
||||
const totalSizeSentThisMonth = transfersThisMonth.reduce((acc, t) => acc + BigInt(t.totalSize), BigInt(0));
|
||||
if (
|
||||
(limits.maxTransfersPerMonth !== Infinity && transfersThisMonth.length >= limits.maxTransfersPerMonth) ||
|
||||
(limits.maxTransferSizePerMonth !== Infinity && totalSizeSentThisMonth + BigInt(stats.size) > BigInt(limits.maxTransferSizePerMonth))
|
||||
) {
|
||||
await fsPromises.unlink(finalPath);
|
||||
return res.status(429).json({ success: false, message: 'Plan limits exceeded' });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hash = session.password ? await bcrypt.hash(session.password, 10) : null;
|
||||
|
||||
// Parse filenames
|
||||
let totalFiles = 1;
|
||||
if (session.filenames) {
|
||||
try {
|
||||
const filesArray = JSON.parse(session.filenames);
|
||||
if (Array.isArray(filesArray)) {
|
||||
totalFiles = filesArray.length;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + limits.maxExpiryMs);
|
||||
|
||||
// Create transfer record
|
||||
const transfer = await prisma.transfer.create({
|
||||
data: {
|
||||
senderEmail: session.sender,
|
||||
recipientEmail: session.recipient,
|
||||
passwordHash: hash,
|
||||
downloadUrl: uuidv4(),
|
||||
expiresAt,
|
||||
filenames: session.filenames || null,
|
||||
message: session.message || null,
|
||||
totalFiles,
|
||||
totalSize: stats.size,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create file record separately
|
||||
await prisma.file.create({
|
||||
data: {
|
||||
name: session.originalFilename || 'file',
|
||||
path: finalPath,
|
||||
size: stats.size,
|
||||
transferId: transfer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Update transfer with encryption IV in filenames field (temporary workaround)
|
||||
// TODO: Fix Prisma type generation for encryptionIv field
|
||||
if (session.encryptionIv) {
|
||||
// Store IV in a separate metadata approach or update via raw query
|
||||
await (prisma as any).$executeRaw`
|
||||
UPDATE "File" SET "encryptionIv" = ${session.encryptionIv}
|
||||
WHERE "transferId" = ${transfer.id}
|
||||
`;
|
||||
}
|
||||
|
||||
// Send email
|
||||
const link = `${process.env.NEXT_PUBLIC_APP_URL}/download/${transfer.downloadUrl}`;
|
||||
|
||||
await sendMailjetEmail({
|
||||
to: session.recipient,
|
||||
subject: 'You\'ve received an encrypted file',
|
||||
text: `
|
||||
${session.sender} sent you an encrypted file via TransferTribe.
|
||||
|
||||
Link: ${link}
|
||||
|
||||
${session.message ? `Message:\n${session.message}\n\n` : ''}Note: You'll need the password they shared with you to decrypt it.
|
||||
`.trim(),
|
||||
});
|
||||
|
||||
// Clean up session and temp files
|
||||
uploadSessions.delete(uploadId);
|
||||
try {
|
||||
await fsPromises.rm(chunkDir, { recursive: true });
|
||||
} catch (err) {
|
||||
console.warn('Failed to clean up temp directory:', err);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Upload complete',
|
||||
downloadUrl: transfer.downloadUrl,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('Chunk completion error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Completion error: ' + err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// pages/api/send.ts
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { UPLOAD_DIR } from '@/lib/config';
|
||||
import { PLAN_CONFIG } from '@/lib/subscription';
|
||||
import { sendMailjetEmail } from '@/lib/mailjet';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import bcrypt from 'bcrypt';
|
||||
import path from 'path';
|
||||
import { IncomingForm, File as FormidableFile } from 'formidable';
|
||||
import fs from 'fs';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
interface ParsedFields {
|
||||
[key: string]: string | string[] | undefined;
|
||||
}
|
||||
|
||||
interface ParsedFiles {
|
||||
[key: string]: FormidableFile | FormidableFile[] | undefined;
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ success: false, message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const form = new IncomingForm({
|
||||
uploadDir: UPLOAD_DIR,
|
||||
keepExtensions: true,
|
||||
maxFileSize: 100 * 1024 * 1024 * 1024, // 100GB
|
||||
});
|
||||
|
||||
const parseForm = (): Promise<{ fields: ParsedFields; files: ParsedFiles }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
form.parse(req, (err: Error | null, fields: any, files: any) => {
|
||||
if (err) return reject(err);
|
||||
resolve({ fields, files });
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const { fields, files } = await parseForm();
|
||||
|
||||
// Extract form fields
|
||||
const encryptedFile = Array.isArray(files.file) ? files.file[0] : files.file;
|
||||
const recipient = Array.isArray(fields.email) ? fields.email[0] : fields.email;
|
||||
const password = Array.isArray(fields.password) ? fields.password[0] : fields.password;
|
||||
// Sender is taken from the session, never the request body, so a caller
|
||||
// cannot post transfers (and consume quota) as another user.
|
||||
const sender = session.user.email;
|
||||
const filenames = Array.isArray(fields.filenames) ? fields.filenames[0] : fields.filenames;
|
||||
const message = Array.isArray(fields.message) ? fields.message[0] : fields.message;
|
||||
const encryptionIvStr = Array.isArray(fields.encryptionIv) ? fields.encryptionIv[0] : fields.encryptionIv;
|
||||
const originalFilename = Array.isArray(fields.originalFilename) ? fields.originalFilename[0] : fields.originalFilename;
|
||||
|
||||
if (!encryptedFile || !recipient || !sender) {
|
||||
return res.status(400).json({ success: false, message: 'Missing required fields' });
|
||||
}
|
||||
|
||||
// Ensure upload directory exists
|
||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate final filename and move encrypted file
|
||||
const finalFilename = uuidv4() + '.enc';
|
||||
const finalPath = path.join(UPLOAD_DIR, finalFilename);
|
||||
|
||||
// Use streaming copy for large files to avoid permission issues
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const readStream = fs.createReadStream(encryptedFile.filepath);
|
||||
const writeStream = fs.createWriteStream(finalPath);
|
||||
|
||||
readStream.on('error', reject);
|
||||
writeStream.on('error', reject);
|
||||
writeStream.on('finish', resolve);
|
||||
|
||||
readStream.pipe(writeStream);
|
||||
});
|
||||
|
||||
// Clean up temp file after successful copy
|
||||
try {
|
||||
await fsPromises.unlink(encryptedFile.filepath);
|
||||
} catch (err) {
|
||||
console.warn('Failed to clean up temp file:', err);
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
const user = await prisma.user.findUnique({ where: { email: sender } });
|
||||
if (!user) {
|
||||
fs.unlinkSync(finalPath);
|
||||
return res.status(404).json({ success: false, message: 'User not found' });
|
||||
}
|
||||
|
||||
const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro';
|
||||
const limits = PLAN_CONFIG[plan];
|
||||
|
||||
// Check file size against plan limits
|
||||
const stats = fs.statSync(finalPath);
|
||||
if (limits.maxFileSize !== Infinity && stats.size > limits.maxFileSize) {
|
||||
fs.unlinkSync(finalPath);
|
||||
return res.status(413).json({
|
||||
success: false,
|
||||
message: `File exceeds max size for your plan (${plan})`
|
||||
});
|
||||
}
|
||||
|
||||
// Check monthly transfer limits
|
||||
const startOfMonth = new Date();
|
||||
startOfMonth.setDate(1);
|
||||
startOfMonth.setHours(0, 0, 0, 0);
|
||||
|
||||
const transfersThisMonth = await prisma.transfer.findMany({
|
||||
where: {
|
||||
senderEmail: sender,
|
||||
createdAt: { gte: startOfMonth },
|
||||
},
|
||||
});
|
||||
|
||||
const totalSizeSentThisMonth = transfersThisMonth.reduce((acc, t) => acc + BigInt(t.totalSize), BigInt(0));
|
||||
if (
|
||||
limits.maxTransfersPerMonth !== Infinity && transfersThisMonth.length >= limits.maxTransfersPerMonth ||
|
||||
limits.maxTransferSizePerMonth !== Infinity && totalSizeSentThisMonth + BigInt(stats.size) > BigInt(limits.maxTransferSizePerMonth)
|
||||
) {
|
||||
fs.unlinkSync(finalPath);
|
||||
return res.status(429).json({ success: false, message: 'Plan limits exceeded' });
|
||||
}
|
||||
|
||||
// Hash password if provided
|
||||
const hash = password ? await bcrypt.hash(password, 10) : null;
|
||||
|
||||
// Parse filenames if provided
|
||||
let totalFiles = 1;
|
||||
if (filenames) {
|
||||
try {
|
||||
const filesArray = JSON.parse(filenames);
|
||||
if (Array.isArray(filesArray)) {
|
||||
totalFiles = filesArray.length;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + limits.maxExpiryMs);
|
||||
|
||||
// Create transfer record with encrypted file
|
||||
const transfer = await prisma.transfer.create({
|
||||
data: {
|
||||
senderEmail: sender,
|
||||
recipientEmail: recipient,
|
||||
passwordHash: hash,
|
||||
downloadUrl: uuidv4(),
|
||||
expiresAt,
|
||||
filenames: filenames || null,
|
||||
message: message || null,
|
||||
totalFiles,
|
||||
totalSize: stats.size,
|
||||
userId: user?.id,
|
||||
files: {
|
||||
create: [
|
||||
{
|
||||
name: originalFilename || 'file',
|
||||
path: finalPath,
|
||||
size: stats.size,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update file with encryption IV using raw query
|
||||
if (encryptionIvStr) {
|
||||
await (prisma as any).$executeRaw`
|
||||
UPDATE "File" SET "encryptionIv" = ${encryptionIvStr}
|
||||
WHERE "transferId" = ${transfer.id}
|
||||
`;
|
||||
}
|
||||
|
||||
// Send email notification
|
||||
const link = `${process.env.NEXT_PUBLIC_APP_URL}/download/${transfer.downloadUrl}`;
|
||||
|
||||
await sendMailjetEmail({
|
||||
to: recipient,
|
||||
subject: 'You\'ve received an encrypted file',
|
||||
text: `
|
||||
${sender} sent you an encrypted file via TransferTribe.
|
||||
|
||||
Link: ${link}
|
||||
|
||||
${message ? `Message:\n${message}\n\n` : ''}Note: You'll need the password they shared with you to decrypt it.
|
||||
`.trim(),
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error('Upload failed:', err);
|
||||
return res.status(500).json({ success: false, message: 'Upload failed: ' + err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" ADD COLUMN "encryptionIv" TEXT;
|
||||
@@ -0,0 +1,110 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum TransferStatus {
|
||||
ACTIVE
|
||||
EXPIRED
|
||||
DELETED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
plan String @default("free") // "free", "rookie", or "pro"
|
||||
|
||||
// Optional
|
||||
transfers Transfer[] @relation("UserTransfers")
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String?
|
||||
access_token String?
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String?
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
}
|
||||
|
||||
model Transfer {
|
||||
id Int @id @default(autoincrement())
|
||||
senderEmail String
|
||||
recipientEmail String
|
||||
passwordHash String?
|
||||
downloadUrl String @unique
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
files File[]
|
||||
filenames String? // JSON stringified array of filenames
|
||||
totalFiles Int @default(0) // new field to track number of files in transfer
|
||||
totalSize BigInt @default(0) // Use BigInt to support files > 2GB
|
||||
message String? // optional message
|
||||
status TransferStatus @default(ACTIVE) // Added
|
||||
|
||||
userId String? // Change from Int? to String?
|
||||
user User? @relation("UserTransfers", fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
|
||||
model File {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
path String
|
||||
size BigInt // Use BigInt to support files > 2GB
|
||||
encryptionIv String? // 12-byte IV as base64 string for AES-GCM decryption
|
||||
transferId Int
|
||||
transfer Transfer @relation(fields: [transferId], references: [id])
|
||||
downloads FileDownload[] // New
|
||||
}
|
||||
|
||||
model FileDownload {
|
||||
id Int @id @default(autoincrement())
|
||||
fileId Int
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||
timestamp DateTime @default(now())
|
||||
ip String?
|
||||
userAgent String?
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
// app/api/auth/[...nextauth]/route.ts
|
||||
import NextAuth from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
const handler = NextAuth(authOptions)
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
@@ -0,0 +1,51 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { TransferStatus } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* Pre-authentication metadata for the download page.
|
||||
*
|
||||
* This runs BEFORE the visitor has proved they know the password, so it returns
|
||||
* only what the landing screen needs to render. Filenames, the sender's
|
||||
* message, the recipient address and the on-disk file paths are withheld until
|
||||
* /api/verify succeeds.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url)
|
||||
const id = url.searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: false, message: 'No ID provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const transfer = await prisma.transfer.findUnique({
|
||||
where: { downloadUrl: id },
|
||||
include: { files: true },
|
||||
})
|
||||
|
||||
if (!transfer) {
|
||||
return NextResponse.json({ success: false, message: 'Transfer not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const isExpired = transfer.expiresAt <= new Date()
|
||||
|
||||
if (transfer.status !== TransferStatus.ACTIVE || isExpired) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'This transfer is no longer available' },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
|
||||
const totalSize = transfer.files.reduce((sum, file) => sum + file.size, BigInt(0))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
transfer: {
|
||||
senderEmail: transfer.senderEmail,
|
||||
fileCount: transfer.files.length,
|
||||
totalSize: Number(totalSize),
|
||||
expiresAt: transfer.expiresAt.toISOString(),
|
||||
requiresPassword: Boolean(transfer.passwordHash),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcrypt'
|
||||
import fs from 'fs/promises'
|
||||
import { TransferStatus } from '@prisma/client'
|
||||
import { decryptFileStream } from '@/lib/server-encryption'
|
||||
|
||||
// Node APIs (fs handles, crypto) — this route cannot run on the edge runtime.
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/**
|
||||
* Build a Content-Disposition value that survives quotes, newlines and
|
||||
* non-ASCII characters in a user-supplied filename.
|
||||
*/
|
||||
function contentDisposition(filename: string): string {
|
||||
const fallback = filename.replace(/[^\w.\-]+/g, '_') || 'download'
|
||||
const encoded = encodeURIComponent(filename)
|
||||
return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`
|
||||
}
|
||||
|
||||
/**
|
||||
* POST (not GET) so the password travels in the body — as a query parameter it
|
||||
* would be recorded in access logs, proxy logs and Referer headers.
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
let password = ''
|
||||
try {
|
||||
const body = await req.json()
|
||||
password = typeof body?.password === 'string' ? body.password : ''
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return NextResponse.json({ error: 'Password required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const transfer = await prisma.transfer.findUnique({
|
||||
where: { downloadUrl: id },
|
||||
include: { files: true },
|
||||
})
|
||||
|
||||
if (!transfer || !transfer.files.length) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Expiry and soft-deletion are enforced here, not just displayed in the UI.
|
||||
if (transfer.status !== TransferStatus.ACTIVE) {
|
||||
return NextResponse.json({ error: 'This transfer is no longer available' }, { status: 410 })
|
||||
}
|
||||
|
||||
if (transfer.expiresAt <= new Date()) {
|
||||
if (transfer.status === TransferStatus.ACTIVE) {
|
||||
await prisma.transfer.update({
|
||||
where: { id: transfer.id },
|
||||
data: { status: TransferStatus.EXPIRED },
|
||||
})
|
||||
}
|
||||
return NextResponse.json({ error: 'This transfer has expired' }, { status: 410 })
|
||||
}
|
||||
|
||||
// Verify the password against the stored hash BEFORE touching the file or
|
||||
// recording a download. The GCM tag check later is a second, independent gate.
|
||||
if (!transfer.passwordHash) {
|
||||
return NextResponse.json({ error: 'This transfer is not downloadable' }, { status: 409 })
|
||||
}
|
||||
|
||||
const passwordValid = await bcrypt.compare(password, transfer.passwordHash)
|
||||
if (!passwordValid) {
|
||||
return NextResponse.json({ error: 'Incorrect password' }, { status: 401 })
|
||||
}
|
||||
|
||||
const file = transfer.files[0]
|
||||
|
||||
try {
|
||||
await fs.access(file.path)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Only now, after a successful authentication, does this count as a download.
|
||||
await prisma.fileDownload.create({
|
||||
data: {
|
||||
fileId: file.id,
|
||||
ip: req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || null,
|
||||
userAgent: req.headers.get('user-agent') || null,
|
||||
},
|
||||
})
|
||||
|
||||
// Probe the first frame before committing to a 200, so a corrupt or
|
||||
// legacy-format file produces a proper status code rather than a truncated body.
|
||||
const frames = decryptFileStream(file.path, password)
|
||||
let firstFrame: Buffer | undefined
|
||||
try {
|
||||
const first = await frames.next()
|
||||
if (!first.done) firstFrame = first.value
|
||||
} catch (err: any) {
|
||||
await frames.return(undefined as never).catch(() => {})
|
||||
if (err?.message === 'UNSUPPORTED_FORMAT') {
|
||||
console.error(`Legacy-format file for transfer ${transfer.id}: ${file.path}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'This transfer was created with an older, incompatible version and cannot be decrypted. Please ask the sender to resend it.' },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
console.error('Decrypt error:', err)
|
||||
return NextResponse.json({ error: 'Unable to decrypt this file' }, { status: 422 })
|
||||
}
|
||||
|
||||
// Stream the remaining frames so large transfers are never fully buffered.
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
if (firstFrame) controller.enqueue(new Uint8Array(firstFrame))
|
||||
},
|
||||
async pull(controller) {
|
||||
try {
|
||||
const next = await frames.next()
|
||||
if (next.done) {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
controller.enqueue(new Uint8Array(next.value))
|
||||
} catch (err) {
|
||||
console.error('Decrypt error mid-stream:', err)
|
||||
controller.error(err)
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
await frames.return(undefined as never).catch(() => {})
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': contentDisposition(file.name),
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const email = session.user.email;
|
||||
|
||||
// Transfers sent
|
||||
const transfersSent = await prisma.transfer.count({
|
||||
where: { senderEmail: email },
|
||||
});
|
||||
|
||||
// Total files sent - sum of totalFiles field on transfers
|
||||
const totalFilesData = await prisma.transfer.aggregate({
|
||||
where: { senderEmail: email },
|
||||
_sum: {
|
||||
totalFiles: true,
|
||||
},
|
||||
});
|
||||
const totalFilesSent = totalFilesData._sum.totalFiles ?? 0;
|
||||
|
||||
// Downloads - count of fileDownload for files in transfers by this sender
|
||||
const downloads = await prisma.fileDownload.count({
|
||||
where: {
|
||||
file: {
|
||||
transfer: {
|
||||
senderEmail: email,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Active transfers - example: transfers not expired yet
|
||||
const activeTransfers = await prisma.transfer.count({
|
||||
where: {
|
||||
senderEmail: email,
|
||||
expiresAt: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const startOfMonth = new Date();
|
||||
startOfMonth.setDate(1);
|
||||
startOfMonth.setHours(0, 0, 0, 0);
|
||||
|
||||
const transfersThisMonth = await prisma.transfer.count({
|
||||
where: {
|
||||
senderEmail: email,
|
||||
createdAt: {
|
||||
gte: startOfMonth,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const remainingTransfers = Math.max(10 - transfersThisMonth, 0);
|
||||
|
||||
return NextResponse.json({
|
||||
transfersSent,
|
||||
totalFilesSent,
|
||||
downloads,
|
||||
activeTransfers,
|
||||
remainingTransfers,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendMailjetEmail } from '@/lib/mailjet'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ success: false, message: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await context.params
|
||||
const transferId = parseInt(id, 10)
|
||||
|
||||
if (isNaN(transferId)) {
|
||||
return NextResponse.json({ success: false, message: 'Invalid ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Scoped to the sender: without this, any signed-in user could walk the
|
||||
// autoincrement IDs and make the app mail arbitrary recipients.
|
||||
const transfer = await prisma.transfer.findFirst({
|
||||
where: { id: transferId, senderEmail: session.user.email },
|
||||
})
|
||||
|
||||
if (!transfer) {
|
||||
return NextResponse.json({ success: false, message: 'Transfer not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (transfer.status !== 'ACTIVE') {
|
||||
return NextResponse.json({ success: false, message: 'Transfer is not active' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (transfer.expiresAt <= new Date()) {
|
||||
return NextResponse.json({ success: false, message: 'Transfer has expired' }, { status: 400 })
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://transfertribe.com'
|
||||
const link = `${baseUrl}/download/${transfer.downloadUrl}`
|
||||
|
||||
await sendMailjetEmail({
|
||||
to: transfer.recipientEmail,
|
||||
subject: 'Transfer resent: Your file is still available',
|
||||
text: `
|
||||
${transfer.senderEmail} resent you a file via TransferTribe.
|
||||
|
||||
Link: ${link}
|
||||
|
||||
${transfer.message ? `Message:\n${transfer.message}\n\n` : ''}Note: You'll need the password they shared with you to download it.
|
||||
`.trim(),
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Resent successfully' })
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
// Resolves the transfer only if the signed-in user owns it. Returns null for
|
||||
// both "missing" and "not yours" so the response can't be used to enumerate IDs.
|
||||
async function findOwnedTransfer(id: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) return { transfer: null, unauthenticated: true };
|
||||
|
||||
const transferId = Number(id);
|
||||
if (!Number.isInteger(transferId)) return { transfer: null, unauthenticated: false };
|
||||
|
||||
const transfer = await prisma.transfer.findFirst({
|
||||
where: { id: transferId, senderEmail: session.user.email },
|
||||
include: { files: { include: { downloads: true } } },
|
||||
});
|
||||
|
||||
return { transfer, unauthenticated: false };
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { transfer, unauthenticated } = await findOwnedTransfer(id);
|
||||
|
||||
if (unauthenticated) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
if (!transfer) {
|
||||
return NextResponse.json({ error: 'Transfer not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Soft delete: mark status as DELETED
|
||||
await prisma.transfer.update({
|
||||
where: { id: transfer.id },
|
||||
data: { status: 'DELETED' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, id: transfer.id });
|
||||
} catch (error) {
|
||||
console.error('Error deleting transfer:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete transfer' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { transfer, unauthenticated } = await findOwnedTransfer(id);
|
||||
|
||||
if (unauthenticated) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
if (!transfer) {
|
||||
return NextResponse.json({ error: 'Transfer not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// file.size is a BigInt column; seed the accumulator as BigInt and convert
|
||||
// once at the end, since JSON.stringify cannot serialize BigInt.
|
||||
const totalSize = transfer.files.reduce((sum, file) => sum + file.size, BigInt(0));
|
||||
const totalDownloads = transfer.files.reduce((sum, file) => sum + file.downloads.length, 0);
|
||||
|
||||
return NextResponse.json({
|
||||
transfer: {
|
||||
id: transfer.id,
|
||||
senderEmail: transfer.senderEmail,
|
||||
recipientEmail: transfer.recipientEmail,
|
||||
message: transfer.message,
|
||||
filenames: transfer.filenames ? JSON.parse(transfer.filenames) : [],
|
||||
createdAt: transfer.createdAt.toISOString(),
|
||||
expiresAt: transfer.expiresAt.toISOString(),
|
||||
status: transfer.status,
|
||||
totalFiles: transfer.totalFiles,
|
||||
totalSize: Number(totalSize),
|
||||
totalDownloads,
|
||||
files: transfer.files.map((file) => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: Number(file.size),
|
||||
downloadCount: file.downloads.length,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching transfer:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch transfer' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// app/api/transfers/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const email = session.user.email;
|
||||
|
||||
const transfers = await prisma.transfer.findMany({
|
||||
where: {
|
||||
senderEmail: email,
|
||||
},
|
||||
include: {
|
||||
files: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
// `size` is a BigInt column and JSON.stringify throws on BigInt, so every
|
||||
// value crossing the response boundary is converted to Number here.
|
||||
const formattedTransfers = transfers.map((t) => ({
|
||||
id: t.id,
|
||||
senderEmail: t.senderEmail,
|
||||
recipientEmail: t.recipientEmail,
|
||||
message: t.message,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
expiresAt: t.expiresAt.toISOString(),
|
||||
status: t.status,
|
||||
totalFiles: t.totalFiles ?? t.files.length,
|
||||
totalSize: Number(t.files.reduce((sum, f) => sum + f.size, BigInt(0))),
|
||||
downloadCount: 0, // optionally calculate from FileDownload
|
||||
downloadUrl: t.downloadUrl,
|
||||
files: t.files.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
size: Number(f.size),
|
||||
})),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ transfers: formattedTransfers });
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// app/api/usage/route.ts
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const PLAN_LIMITS_MB = {
|
||||
free: 20480, // 10 GB
|
||||
rookie: 30720, // 30 GB
|
||||
pro: 1024 * 1024, // 1 TB
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
select: {
|
||||
id: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const plan = (user.plan || 'free') as keyof typeof PLAN_LIMITS_MB;
|
||||
const maxMonthlyMB = PLAN_LIMITS_MB[plan];
|
||||
|
||||
const monthStart = new Date();
|
||||
monthStart.setDate(1);
|
||||
monthStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const usedBytes = await prisma.file.aggregate({
|
||||
_sum: {
|
||||
size: true,
|
||||
},
|
||||
where: {
|
||||
transfer: {
|
||||
is: {
|
||||
userId: user.id,
|
||||
createdAt: {
|
||||
gte: monthStart,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const usedMB = Math.ceil(Number(usedBytes._sum.size || 0) / 1024 / 1024);
|
||||
const remainingMB = Math.max(maxMonthlyMB - usedMB, 0);
|
||||
console.log('Used bytes:', usedBytes);
|
||||
|
||||
return NextResponse.json({
|
||||
plan,
|
||||
usedMB,
|
||||
remainingMB,
|
||||
maxMonthlyMB,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// app/api/verify/route.ts
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { TransferStatus } from '@prisma/client'
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { id, password } = await req.json()
|
||||
|
||||
if (!id || !password) {
|
||||
return NextResponse.json({ success: false, message: 'Missing ID or password' }, { status: 400 })
|
||||
}
|
||||
|
||||
const transfer = await prisma.transfer.findUnique({
|
||||
where: { downloadUrl: id },
|
||||
include: { files: true },
|
||||
})
|
||||
|
||||
if (!transfer) {
|
||||
return NextResponse.json({ success: false, message: 'Transfer not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (transfer.status !== TransferStatus.ACTIVE || transfer.expiresAt <= new Date()) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'This transfer is no longer available' },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!transfer.passwordHash) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'This transfer is not downloadable' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(password, transfer.passwordHash)
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ success: false, message: 'Incorrect password' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Explicit allow-list rather than spreading the row: `files` carries absolute
|
||||
// server paths, and the row carries passwordHash and the owning userId.
|
||||
const totalSize = transfer.files.reduce((sum, file) => sum + file.size, BigInt(0))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
transfer: {
|
||||
senderEmail: transfer.senderEmail,
|
||||
message: transfer.message,
|
||||
createdAt: transfer.createdAt.toISOString(),
|
||||
expiresAt: transfer.expiresAt.toISOString(),
|
||||
totalFiles: transfer.totalFiles,
|
||||
totalSize: Number(totalSize),
|
||||
filenames: transfer.filenames ? JSON.parse(transfer.filenames) : [],
|
||||
files: transfer.files.map((file) => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: Number(file.size),
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { signIn, getSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Upload,
|
||||
Mail,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Github,
|
||||
Chrome,
|
||||
ArrowLeft,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function SignInPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleEmailSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email || !password) {
|
||||
toast.error('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
toast.error('Invalid credentials');
|
||||
} else {
|
||||
toast.success('Welcome back!');
|
||||
router.push('/');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Something went wrong');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderSignIn = async (provider: string) => {
|
||||
setLoadingProvider(provider);
|
||||
try {
|
||||
await signIn(provider, { callbackUrl: '/send' });
|
||||
} catch (error) {
|
||||
toast.error(`Failed to sign in with ${provider}`);
|
||||
setLoadingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6 text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back to Transfer Tribe</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-center space-x-2 mb-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<Upload className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
Transfer Tribe
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-2">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Sign in to your account to continue sharing files securely
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-white/20 dark:border-slate-700/20">
|
||||
<CardHeader className="space-y-1 pb-4">
|
||||
<CardTitle className="text-xl text-center text-slate-900 dark:text-slate-100">
|
||||
Sign in to your account
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Social Sign In */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-11"
|
||||
onClick={() => handleProviderSignIn('google')}
|
||||
disabled={loadingProvider === 'google'}
|
||||
>
|
||||
{loadingProvider === 'google' ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Chrome className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-11"
|
||||
onClick={() => handleProviderSignIn('github')}
|
||||
disabled={loadingProvider === 'github'}
|
||||
>
|
||||
{loadingProvider === 'github' ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Github className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white dark:bg-slate-800 px-2 text-slate-500 dark:text-slate-400">
|
||||
Or continue with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Sign In */}
|
||||
<form onSubmit={handleEmailSignIn} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-4 h-4" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="pl-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-4 h-4" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
className="text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
href="/auth/signup"
|
||||
className="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
By signing in, you agree to our{' '}
|
||||
<Link href="/terms" className="underline hover:text-slate-700 dark:hover:text-slate-300">
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link href="/privacy" className="underline hover:text-slate-700 dark:hover:text-slate-300">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Upload,
|
||||
Mail,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Github,
|
||||
Chrome,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function SignUpPage() {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [acceptTerms, setAcceptTerms] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleEmailSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name || !email || !password || !confirmPassword) {
|
||||
toast.error('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
toast.error('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!acceptTerms) {
|
||||
toast.error('Please accept the terms and conditions');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// In a real app, you would create the user account first
|
||||
// For demo purposes, we'll simulate account creation and then sign in
|
||||
|
||||
// Simulate API call delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
toast.error('Failed to create account');
|
||||
} else {
|
||||
toast.success('Account created successfully!');
|
||||
router.push('/');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Something went wrong');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderSignIn = async (provider: string) => {
|
||||
setLoadingProvider(provider);
|
||||
try {
|
||||
await signIn(provider, { callbackUrl: '/' });
|
||||
} catch (error) {
|
||||
toast.error(`Failed to sign up with ${provider}`);
|
||||
setLoadingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6 text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back to Transfer Tribe</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-center space-x-2 mb-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<Upload className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
Transfer Tribe
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-2">
|
||||
Create your account
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Join thousands of users sharing files securely
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-white/20 dark:border-slate-700/20">
|
||||
<CardHeader className="space-y-1 pb-4">
|
||||
<CardTitle className="text-xl text-center text-slate-900 dark:text-slate-100">
|
||||
Sign up for Transfer Tribe
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Social Sign Up */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-11"
|
||||
onClick={() => handleProviderSignIn('google')}
|
||||
disabled={loadingProvider === 'google'}
|
||||
>
|
||||
{loadingProvider === 'google' ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Chrome className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-11"
|
||||
onClick={() => handleProviderSignIn('github')}
|
||||
disabled={loadingProvider === 'github'}
|
||||
>
|
||||
{loadingProvider === 'github' ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Github className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white dark:bg-slate-800 px-2 text-slate-500 dark:text-slate-400">
|
||||
Or continue with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Sign Up */}
|
||||
<form onSubmit={handleEmailSignUp} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-4 h-4" />
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Enter your full name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="pl-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-4 h-4" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="pl-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-4 h-4" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Create a password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Must be at least 8 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-4 h-4" />
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
placeholder="Confirm your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={acceptTerms}
|
||||
onCheckedChange={(checked) => setAcceptTerms(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="terms" className="text-sm text-slate-600 dark:text-slate-400">
|
||||
I agree to the{' '}
|
||||
<Link href="/terms" className="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link href="/privacy" className="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
'Create account'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
By creating an account, you agree to our Terms of Service and Privacy Policy
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { decryptBlob } from '@/lib/encryption';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Download,
|
||||
File,
|
||||
FileText,
|
||||
Image,
|
||||
Music,
|
||||
Video,
|
||||
Archive,
|
||||
Clock,
|
||||
Shield,
|
||||
User,
|
||||
MessageSquare,
|
||||
CheckCircle,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
AlertCircle,
|
||||
Mail,
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { format } from 'date-fns';
|
||||
import { formatFileSize, formatTimeRemaining } from '@/lib/utils';
|
||||
|
||||
export default function DownloadPage() {
|
||||
const { id } = useParams() as { id: string };
|
||||
|
||||
const [transfer, setTransfer] = useState<any>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [downloadingFiles, setDownloadingFiles] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [downloadingAll, setDownloadingAll] = useState(false);
|
||||
const [downloadedFiles, setDownloadedFiles] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const loadTransfer = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/download?id=${encodeURIComponent(id)}`);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
setTransfer(data.transfer);
|
||||
} else {
|
||||
setStatus(data.message || 'Download not found or expired.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setStatus('Failed to load transfer.');
|
||||
}
|
||||
};
|
||||
|
||||
loadTransfer();
|
||||
}, [id]);
|
||||
|
||||
const handleDecrypt = async () => {
|
||||
try {
|
||||
setDownloading(true);
|
||||
setDownloadingAll(true);
|
||||
setStatus('Downloading and decrypting file...');
|
||||
|
||||
// POST so the password stays out of the URL (and therefore out of access
|
||||
// logs and Referer headers).
|
||||
const res = await fetch(`/api/file/${encodeURIComponent(id)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(err?.error || 'File not found or invalid password.');
|
||||
}
|
||||
|
||||
const decryptedBlob = await res.blob();
|
||||
const url = URL.createObjectURL(decryptedBlob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = transfer.files?.[0]?.name?.replace(/\.enc$/, '') || 'file';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setStatus('Download complete!');
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setStatus('Download failed: ' + err.message);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
setDownloadingAll(false);
|
||||
setDownloadedFiles(new Set(transfer.files.map((f: any) => f.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsVerifying(true);
|
||||
setPasswordError('');
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
setTransfer(data.transfer);
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setPasswordError(
|
||||
data.message || 'Incorrect password. Please try again.'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setPasswordError('Something went wrong. Try again.');
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isFileDownloading = (fileId: string) => downloadingFiles.has(fileId);
|
||||
const isFileDownloaded = (fileId: string) => downloadedFiles.has(fileId);
|
||||
|
||||
if (!transfer) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center mx-auto mb-4 animate-pulse">
|
||||
<File className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Loading transfer...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 relative overflow-hidden">
|
||||
{/* Modern Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-900 via-blue-900/20 to-purple-900/20"></div>
|
||||
<div className="absolute inset-0 bg-[url('https://images.pexels.com/photos/1103970/pexels-photo-1103970.jpeg?auto=compress&cs=tinysrgb&w=1920&h=1080&fit=crop')] bg-cover bg-center opacity-10"></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-900/90 via-gray-900/50 to-gray-900/90"></div>
|
||||
|
||||
{/* Animated Background Elements */}
|
||||
<div className="absolute top-20 left-20 w-72 h-72 bg-blue-500/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-emerald-500/10 rounded-full blur-3xl animate-pulse delay-500"></div>
|
||||
<div className="relative z-10 container mx-auto px-4 py-8 max-w-lg min-h-screen flex items-center justify-center">
|
||||
<div className="w-full space-y-6">
|
||||
{/* Transfer Info Card */}
|
||||
<Card className="border-gray-800 bg-gray-900/80 backdrop-blur-xl shadow-2xl">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-600 rounded-2xl mx-auto mb-4">
|
||||
<Download className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-xl font-bold text-white mb-2">
|
||||
File Transfer
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
You have received files from {transfer.senderEmail}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{/* Sender Info */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<User className="h-4 w-4 text-gray-500 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-gray-400">
|
||||
{transfer.senderEmail}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-gray-800 mb-6" />
|
||||
|
||||
{/* Transfer Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="h-4 w-4 text-gray-500" />
|
||||
<div>
|
||||
<div className="text-gray-400">Files</div>
|
||||
<div className="text-white font-medium">
|
||||
{transfer.fileCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-gray-500" />
|
||||
<div>
|
||||
<div className="text-gray-400">Total Size</div>
|
||||
<div className="text-white font-medium">
|
||||
{formatFileSize(transfer.totalSize)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2 text-sm">
|
||||
<Clock className="h-4 w-4 text-gray-500" />
|
||||
<div>
|
||||
<span className="text-gray-400">Expires: </span>
|
||||
<span className="text-white font-medium">
|
||||
{formatTimeRemaining(new Date(transfer.expiresAt))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-gray-800 bg-gray-900/80 backdrop-blur-xl shadow-2xl">
|
||||
<CardHeader className="text-center pb-6">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-orange-600 rounded-xl mx-auto mb-3">
|
||||
<Lock className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-lg font-bold text-white mb-1">
|
||||
Protected Transfer
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
Enter the password to access your files
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<form onSubmit={handlePasswordSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium text-gray-300"
|
||||
>
|
||||
Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter transfer password"
|
||||
className="bg-gray-800 border-gray-700 text-white placeholder-gray-500 pr-12 h-12 focus:border-blue-500 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{passwordError && (
|
||||
<div className="flex items-center gap-2 text-red-400 text-sm mt-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{passwordError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isVerifying || !password}
|
||||
className="w-full h-12 bg-blue-600 hover:bg-blue-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
{isVerifying ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="h-5 w-5 mr-2" />
|
||||
Access Files
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-800">
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-gray-500">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>Secure encrypted transfer</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 relative overflow-hidden">
|
||||
{/* Modern Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-900 via-blue-900/20 to-purple-900/20"></div>
|
||||
<div className="absolute inset-0 bg-[url('https://images.pexels.com/photos/1103970/pexels-photo-1103970.jpeg?auto=compress&cs=tinysrgb&w=1920&h=1080&fit=crop')] bg-cover bg-center opacity-10"></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-900/90 via-gray-900/50 to-gray-900/90"></div>
|
||||
|
||||
{/* Animated Background Elements */}
|
||||
<div className="absolute top-20 left-20 w-72 h-72 bg-blue-500/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
||||
|
||||
<div className="relative z-10 container mx-auto px-4 py-8 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 mb-4">
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<Download className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">FileTransfer</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
Download Your Files
|
||||
</h1>
|
||||
<p className="text-gray-400 text-lg">
|
||||
Your files are ready for download
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Transfer Info Card */}
|
||||
<Card className="mb-8 border-gray-800 bg-gray-900/80 backdrop-blur-xl shadow-2xl">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-xl mb-2 text-white">
|
||||
Transfer from {transfer.senderName}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
<div className="flex items-center gap-2 mb-1 text-gray-400">
|
||||
<User className="h-4 w-4" />
|
||||
{transfer.senderEmail}
|
||||
</div>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-emerald-900/50 text-emerald-300 border-emerald-700"
|
||||
>
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Secure Transfer
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{transfer.message && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<MessageSquare className="h-4 w-4 text-gray-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-300">
|
||||
Message:
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-400 leading-relaxed ml-6">
|
||||
{transfer.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="h-4 w-4" />
|
||||
<span>{transfer.files.length} files</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
<span>{formatFileSize(transfer.totalSize)} total</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Expires {transfer.expiresAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Download All Button */}
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
onClick={handleDecrypt}
|
||||
disabled={downloadingAll}
|
||||
size="lg"
|
||||
className="w-full h-12 px-8 bg-blue-600 hover:bg-blue-700 text-white shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
{downloadingAll ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2" />
|
||||
Preparing Download...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-5 w-5 mr-2" />
|
||||
Download All Files {formatFileSize(transfer.totalSize)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="mb-6 bg-gray-800" />
|
||||
|
||||
{/* Files List */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">
|
||||
Files in this transfer
|
||||
</h2>
|
||||
{transfer.filenames?.map((name: string, index: number) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="border-gray-800 bg-gray-900/60 backdrop-blur-xl hover:bg-gray-900/80 transition-all duration-200"
|
||||
>
|
||||
<CardContent>
|
||||
<h3 className="font-medium text-white truncate">{name}</h3>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-12 text-center">
|
||||
<div className="inline-flex items-center gap-2 text-sm text-gray-500 mb-4">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>All downloads are secure and encrypted</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
This transfer will expire on {transfer.expiresAt}. Download your
|
||||
files before this date.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+110
-14
@@ -1,26 +1,122 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.147 0.004 49.25);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.147 0.004 49.25);
|
||||
--primary: oklch(0.216 0.006 56.043);
|
||||
--primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--secondary: oklch(0.97 0.001 106.424);
|
||||
--secondary-foreground: oklch(0.216 0.006 56.043);
|
||||
--muted: oklch(0.97 0.001 106.424);
|
||||
--muted-foreground: oklch(0.553 0.013 58.071);
|
||||
--accent: oklch(0.97 0.001 106.424);
|
||||
--accent-foreground: oklch(0.216 0.006 56.043);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.923 0.003 48.717);
|
||||
--input: oklch(0.923 0.003 48.717);
|
||||
--ring: oklch(0.709 0.01 56.259);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0.001 106.423);
|
||||
--sidebar-foreground: oklch(0.147 0.004 49.25);
|
||||
--sidebar-primary: oklch(0.216 0.006 56.043);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-accent: oklch(0.97 0.001 106.424);
|
||||
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
|
||||
--sidebar-border: oklch(0.923 0.003 48.717);
|
||||
--sidebar-ring: oklch(0.709 0.01 56.259);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.147 0.004 49.25);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.147 0.004 49.25);
|
||||
--foreground: oklch(0.985 0.001 106.423);
|
||||
--card: oklch(0.216 0.006 56.043);
|
||||
--card-foreground: oklch(0.985 0.001 106.423);
|
||||
--popover: oklch(0.216 0.006 56.043);
|
||||
--popover-foreground: oklch(0.985 0.001 106.423);
|
||||
--primary: oklch(0.923 0.003 48.717);
|
||||
--primary-foreground: oklch(0.216 0.006 56.043);
|
||||
--secondary: oklch(0.268 0.007 34.298);
|
||||
--secondary-foreground: oklch(0.985 0.001 106.423);
|
||||
--muted: oklch(0.268 0.007 34.298);
|
||||
--muted-foreground: oklch(0.709 0.01 56.259);
|
||||
--accent: oklch(0.268 0.007 34.298);
|
||||
--accent-foreground: oklch(0.985 0.001 106.423);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.553 0.013 58.071);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.216 0.006 56.043);
|
||||
--sidebar-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-accent: oklch(0.268 0.007 34.298);
|
||||
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.553 0.013 58.071);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
+25
-22
@@ -1,34 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import './globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { AuthProvider } from '@/components/auth-provider';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: 'Transfer Tribe - Secure File Sharing',
|
||||
description: 'Fast, secure, and reliable file transfer service powered by Google Cloud Storage',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<AuthProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
+271
-96
@@ -1,103 +1,278 @@
|
||||
import Image from "next/image";
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { Header } from '@/components/header';
|
||||
import { HeroSection } from '@/components/hero-section';
|
||||
import { Stats } from '@/components/stats';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
ArrowRight,
|
||||
Shield,
|
||||
Zap,
|
||||
Globe,
|
||||
Mail,
|
||||
Lock,
|
||||
Clock,
|
||||
Users,
|
||||
Star,
|
||||
CheckCircle
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
useEffect(() => {
|
||||
// Redirect logged-in users to dashboard
|
||||
if (session) {
|
||||
router.push('/send');
|
||||
}
|
||||
}, [session, router]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center mx-auto mb-4 animate-pulse">
|
||||
<Mail className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (session) {
|
||||
return null; // Will redirect to dashboard
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<HeroSection />
|
||||
|
||||
<div className="max-w-6xl mx-auto space-y-16">
|
||||
<Stats />
|
||||
|
||||
{/* Features Section */}
|
||||
<section id="features" className="space-y-12">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-slate-900 dark:text-slate-100 mb-4">
|
||||
Why Choose Transfer Tribe?
|
||||
</h2>
|
||||
<p className="text-xl text-slate-600 dark:text-slate-400 max-w-2xl mx-auto">
|
||||
Built for security, designed for simplicity. Share files with confidence.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-white/20 dark:border-slate-700/20 hover:bg-white/90 dark:hover:bg-slate-800/90 transition-all">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-green-400 to-emerald-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Shield className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-900 dark:text-slate-100 mb-4">
|
||||
Enterprise Security
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-400 leading-relaxed">
|
||||
Bank-level encryption, password protection, and automatic expiration ensure your files stay secure.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-white/20 dark:border-slate-700/20 hover:bg-white/90 dark:hover:bg-slate-800/90 transition-all">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-400 to-blue-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Zap className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-900 dark:text-slate-100 mb-4">
|
||||
Lightning Fast
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-400 leading-relaxed">
|
||||
Powered by Google Cloud Storage for maximum speed and reliability. Upload and share in seconds.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-white/20 dark:border-slate-700/20 hover:bg-white/90 dark:hover:bg-slate-800/90 transition-all">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-purple-400 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Mail className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-900 dark:text-slate-100 mb-4">
|
||||
Email Integration
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-400 leading-relaxed">
|
||||
Recipients get secure download links directly in their inbox. No accounts required to download.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className="space-y-12">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-slate-900 dark:text-slate-100 mb-4">
|
||||
How It Works
|
||||
</h2>
|
||||
<p className="text-xl text-slate-600 dark:text-slate-400 max-w-2xl mx-auto">
|
||||
Share files in three simple steps
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="text-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 mx-auto text-white font-bold text-xl">
|
||||
1
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
Upload Files
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Drag and drop your files or click to select. Files up to 2GB are supported on the free plan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full flex items-center justify-center mx-auto text-white font-bold text-xl">
|
||||
2
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
Add Recipients
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Enter recipient email addresses and add an optional personal message.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-pink-600 rounded-full flex items-center justify-center mx-auto text-white font-bold text-xl">
|
||||
3
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
Send Securely
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Recipients receive an email with a secure download link and password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Testimonials */}
|
||||
<section className="space-y-12">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-slate-900 dark:text-slate-100 mb-4">
|
||||
Trusted by Thousands
|
||||
</h2>
|
||||
<p className="text-xl text-slate-600 dark:text-slate-400 max-w-2xl mx-auto">
|
||||
See what our users are saying about Transfer Tribe
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-white/20 dark:border-slate-700/20">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-1 mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-4 h-4 fill-yellow-400 text-yellow-400" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400 mb-4">
|
||||
"Transfer Tribe has revolutionized how we share large design files with clients. The security features give us peace of mind."
|
||||
</p>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center text-white font-semibold">
|
||||
S
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Sarah Chen</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Creative Director</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-white/20 dark:border-slate-700/20">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-1 mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-4 h-4 fill-yellow-400 text-yellow-400" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400 mb-4">
|
||||
"Simple, fast, and secure. Exactly what we needed for sharing confidential documents with our legal team."
|
||||
</p>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full flex items-center justify-center text-white font-semibold">
|
||||
M
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Michael Rodriguez</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Business Owner</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-white/20 dark:border-slate-700/20">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-1 mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className="w-4 h-4 fill-yellow-400 text-yellow-400" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400 mb-4">
|
||||
"The email integration is brilliant. Our clients love how easy it is to receive and download files."
|
||||
</p>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-600 rounded-full flex items-center justify-center text-white font-semibold">
|
||||
A
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Alex Thompson</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Freelancer</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="text-center space-y-8">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-indigo-600 rounded-2xl p-12 text-white">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">
|
||||
Ready to Start Sharing?
|
||||
</h2>
|
||||
<p className="text-xl text-blue-100 mb-8 max-w-2xl mx-auto">
|
||||
Join thousands of users who trust Transfer Tribe for secure file sharing.
|
||||
Get started with our free plan today.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href="/auth/signup">
|
||||
<Button size="lg" className="bg-white text-blue-600 hover:bg-blue-50 px-8 py-3 rounded-full shadow-lg hover:shadow-xl transition-all">
|
||||
Get Started Free
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/pricing">
|
||||
<Button variant="outline" size="lg" className="border-white text-white hover:bg-white/10 px-8 py-3 rounded-full">
|
||||
View Pricing
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Check,
|
||||
X,
|
||||
Zap,
|
||||
Crown,
|
||||
Gift,
|
||||
Upload,
|
||||
Clock,
|
||||
Shield,
|
||||
Users,
|
||||
Mail,
|
||||
Settings,
|
||||
Infinity
|
||||
} from 'lucide-react';
|
||||
|
||||
interface PricingTier {
|
||||
name: string;
|
||||
price: string;
|
||||
period: string;
|
||||
description: string;
|
||||
icon: React.ComponentType<any>;
|
||||
popular?: boolean;
|
||||
features: {
|
||||
name: string;
|
||||
included: boolean;
|
||||
value?: string;
|
||||
}[];
|
||||
cta: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export default function PricingPage() {
|
||||
const [isAnnual, setIsAnnual] = useState(false);
|
||||
|
||||
const tiers: PricingTier[] = [
|
||||
{
|
||||
name: 'Free',
|
||||
price: '$0',
|
||||
period: 'forever',
|
||||
description: 'Perfect for personal use and trying out Transfer Tribe',
|
||||
icon: Gift,
|
||||
color: 'from-slate-500 to-slate-600',
|
||||
cta: 'Get Started Free',
|
||||
features: [
|
||||
{ name: 'File size limit', included: true, value: '2GB per transfer' },
|
||||
{ name: 'Monthly transfers', included: true, value: '10 transfers' },
|
||||
{ name: 'Transfer expiration', included: true, value: '48 hours' },
|
||||
{ name: 'Password protection', included: true },
|
||||
{ name: 'Email notifications', included: true },
|
||||
{ name: 'Basic support', included: true },
|
||||
{ name: 'Custom branding', included: false },
|
||||
{ name: 'Extended expiration', included: false },
|
||||
{ name: 'Priority support', included: false },
|
||||
{ name: 'Advanced analytics', included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Rookie',
|
||||
price: isAnnual ? '$8' : '$10',
|
||||
period: isAnnual ? 'per month (billed annually)' : 'per month',
|
||||
description: 'Ideal for professionals and small teams who need more flexibility',
|
||||
icon: Zap,
|
||||
popular: true,
|
||||
color: 'from-blue-500 to-indigo-600',
|
||||
cta: 'Start Free Trial',
|
||||
features: [
|
||||
{ name: 'File size limit', included: true, value: '10GB per transfer' },
|
||||
{ name: 'Monthly transfers', included: true, value: '100 transfers' },
|
||||
{ name: 'Transfer expiration', included: true, value: 'Up to 7 days' },
|
||||
{ name: 'Password protection', included: true },
|
||||
{ name: 'Email notifications', included: true },
|
||||
{ name: 'Priority support', included: true },
|
||||
{ name: 'Download tracking', included: true },
|
||||
{ name: 'Transfer history', included: true, value: '6 months' },
|
||||
{ name: 'Custom branding', included: false },
|
||||
{ name: 'Advanced analytics', included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: isAnnual ? '$20' : '$25',
|
||||
period: isAnnual ? 'per month (billed annually)' : 'per month',
|
||||
description: 'For businesses and power users who need maximum control',
|
||||
icon: Crown,
|
||||
color: 'from-purple-500 to-pink-600',
|
||||
cta: 'Start Free Trial',
|
||||
features: [
|
||||
{ name: 'File size limit', included: true, value: 'Unlimited' },
|
||||
{ name: 'Monthly transfers', included: true, value: 'Unlimited' },
|
||||
{ name: 'Transfer expiration', included: true, value: 'Custom (1-30 days)' },
|
||||
{ name: 'Password protection', included: true },
|
||||
{ name: 'Email notifications', included: true },
|
||||
{ name: 'Priority support', included: true },
|
||||
{ name: 'Download tracking', included: true },
|
||||
{ name: 'Transfer history', included: true, value: 'Unlimited' },
|
||||
{ name: 'Custom branding', included: true },
|
||||
{ name: 'Advanced analytics', included: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl 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 mb-4">
|
||||
Choose Your Plan
|
||||
</h1>
|
||||
<p className="text-xl text-slate-600 dark:text-slate-400 max-w-2xl mx-auto mb-8">
|
||||
Start free and upgrade as you grow. All plans include our core security features and 99.9% uptime guarantee.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 mb-8">
|
||||
<span className={`text-sm font-medium ${!isAnnual ? 'text-slate-900 dark:text-slate-100' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||
Monthly
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsAnnual(!isAnnual)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
isAnnual ? 'bg-blue-600' : 'bg-slate-200 dark:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
isAnnual ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className={`text-sm font-medium ${isAnnual ? 'text-slate-900 dark:text-slate-100' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||
Annual
|
||||
</span>
|
||||
{isAnnual && (
|
||||
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400">
|
||||
Save 20%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-7xl mx-auto">
|
||||
{tiers.map((tier, index) => {
|
||||
const IconComponent = tier.icon;
|
||||
return (
|
||||
<Card
|
||||
key={tier.name}
|
||||
className={`relative bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-white/20 dark:border-slate-700/20 hover:bg-white/90 dark:hover:bg-slate-800/90 transition-all ${
|
||||
tier.popular ? 'ring-2 ring-blue-500 scale-105' : ''
|
||||
}`}
|
||||
>
|
||||
{tier.popular && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-600 text-white px-4 py-1">
|
||||
Most Popular
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="text-center pb-8">
|
||||
<div className={`w-16 h-16 bg-gradient-to-br ${tier.color} rounded-full flex items-center justify-center mx-auto mb-4`}>
|
||||
<IconComponent className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{tier.name}
|
||||
</CardTitle>
|
||||
<div className="mt-4">
|
||||
<span className="text-4xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{tier.price}
|
||||
</span>
|
||||
<span className="text-slate-500 dark:text-slate-400 ml-2">
|
||||
{tier.period}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400 mt-4">
|
||||
{tier.description}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<Button
|
||||
className={`w-full ${
|
||||
tier.popular
|
||||
? 'bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700'
|
||||
: 'bg-gradient-to-r from-slate-600 to-slate-700 hover:from-slate-700 hover:to-slate-800'
|
||||
}`}
|
||||
size="lg"
|
||||
>
|
||||
{tier.cta}
|
||||
</Button>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
What's included:
|
||||
</h4>
|
||||
<ul className="space-y-3">
|
||||
{tier.features.map((feature, featureIndex) => (
|
||||
<li key={featureIndex} className="flex items-start gap-3">
|
||||
{feature.included ? (
|
||||
<Check className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="w-5 h-5 text-slate-400 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<span className={`text-sm ${
|
||||
feature.included
|
||||
? 'text-slate-700 dark:text-slate-300'
|
||||
: 'text-slate-400 dark:text-slate-500'
|
||||
}`}>
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.value && (
|
||||
<span className={`block text-xs ${
|
||||
feature.included
|
||||
? 'text-slate-500 dark:text-slate-400'
|
||||
: 'text-slate-400 dark:text-slate-500'
|
||||
}`}>
|
||||
{feature.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-8">
|
||||
Why Choose Transfer Tribe?
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 max-w-4xl mx-auto">
|
||||
<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">
|
||||
<Shield className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Enterprise Security</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 text-center">
|
||||
Bank-level encryption and security protocols
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-400 to-blue-600 rounded-full flex items-center justify-center">
|
||||
<Upload className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Lightning Fast</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 text-center">
|
||||
Powered by Google Cloud for maximum speed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-400 to-purple-600 rounded-full flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Team Friendly</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 text-center">
|
||||
Perfect for individuals and teams of any size
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-orange-400 to-red-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 Integration</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 text-center">
|
||||
Seamless email delivery and notifications
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 bg-white/60 dark:bg-slate-800/60 backdrop-blur-sm rounded-2xl p-8 text-center border border-white/20 dark:border-slate-700/20">
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-4">
|
||||
Need a Custom Solution?
|
||||
</h2>
|
||||
<p className="text-slate-600 dark:text-slate-400 mb-6 max-w-2xl mx-auto">
|
||||
For enterprise customers with specific requirements, we offer custom plans with dedicated support,
|
||||
advanced integrations, and tailored features.
|
||||
</p>
|
||||
<Button variant="outline" size="lg" className="mr-4">
|
||||
Contact Sales
|
||||
</Button>
|
||||
<Button variant="outline" size="lg">
|
||||
View Enterprise Features
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
All plans include a 14-day free trial. No credit card required. Cancel anytime.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Header } from '@/components/header';
|
||||
import SendPage from '@/components/send-transfer-server-encrypted';
|
||||
import { MyTransfers } from '@/components/my-transfers';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Upload,
|
||||
Send,
|
||||
History,
|
||||
Files,
|
||||
TrendingUp,
|
||||
Download,
|
||||
Clock,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [activeTab, setActiveTab] = useState<'send' | 'transfers' | 'files'>(
|
||||
'send'
|
||||
);
|
||||
const [userFiles, setUserFiles] = useState<any[]>([]);
|
||||
const { data: session, status } = useSession();
|
||||
const userName = session?.user?.name || 'friend';
|
||||
|
||||
const handleFileUpload = (files: any[]) => {
|
||||
setUserFiles((prev) => [...prev, ...files]);
|
||||
};
|
||||
|
||||
const [stats, setStats] = useState({
|
||||
transfersSent: 0,
|
||||
downloads: 0,
|
||||
totalFilesSent: 0,
|
||||
activeTransfers: 0,
|
||||
remainingTransfers: 10,
|
||||
loading: true,
|
||||
});
|
||||
|
||||
const [usage, setUsage] = useState({
|
||||
plan: 'free',
|
||||
usedMB: 0,
|
||||
remainingMB: 0,
|
||||
maxMonthlyMB: 20480,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await fetch('/api/stats');
|
||||
const data = await res.json();
|
||||
setStats({ ...data, loading: false });
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stats:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsage() {
|
||||
try {
|
||||
const res = await fetch('/api/usage');
|
||||
const data = await res.json();
|
||||
setUsage(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch usage:', err);
|
||||
}
|
||||
}
|
||||
|
||||
fetchStats();
|
||||
fetchUsage();
|
||||
}, []);
|
||||
|
||||
const usagePercent = (usage.usedMB / usage.maxMonthlyMB) * 100;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Welcome Section */}
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 dark:text-slate-100">
|
||||
Welcome back, {userName}!
|
||||
</h1>
|
||||
<p className="text-lg text-slate-600 dark:text-slate-400">
|
||||
Manage your file transfers and share files securely
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-white/20 dark:border-slate-700/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<Send className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.loading ? '...' : stats.transfersSent}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
Transfers Sent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-white/20 dark:border-slate-700/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-green-500 to-emerald-600 rounded-lg flex items-center justify-center">
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.loading ? '...' : stats.downloads}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
Downloads
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-white/20 dark:border-slate-700/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-600 rounded-lg flex items-center justify-center">
|
||||
<Files className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.loading ? '...' : stats.totalFilesSent}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
Files Uploaded
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-white/20 dark:border-slate-700/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-orange-500 to-red-600 rounded-lg flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.loading ? '...' : stats.activeTransfers}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
Active Transfers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Account Status */}
|
||||
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-white/20 dark:border-slate-700/20">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-green-400 to-emerald-600 rounded-full flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{usage.plan.charAt(0).toUpperCase() + usage.plan.slice(1)}{' '}
|
||||
Plan
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{stats.loading
|
||||
? 'Loading usage...'
|
||||
: usage.plan === 'free'
|
||||
? '2 GB max transfer size'
|
||||
: `${usage.maxMonthlyMB / 1024} GB monthly bandwidth`}
|
||||
</p>
|
||||
{usage.plan === 'free' && (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{stats.remainingTransfers} transfers remaining
|
||||
</p>
|
||||
)}
|
||||
{usage.plan !== 'free' && (
|
||||
<>
|
||||
<Progress value={usagePercent} className="mt-2 h-2" />
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{usage.usedMB} MB used of {usage.maxMonthlyMB} MB
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400">
|
||||
Active
|
||||
</Badge>
|
||||
<button className="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 font-medium text-sm">
|
||||
Upgrade Plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Main Dashboard */}
|
||||
<div className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm rounded-2xl shadow-xl border border-white/20 dark:border-slate-700/20 overflow-hidden">
|
||||
<div className="border-b border-slate-200 dark:border-slate-700">
|
||||
<nav className="flex space-x-8 px-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('send')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors flex items-center space-x-2 ${
|
||||
activeTab === 'send'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
<span>Send Transfer</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('transfers')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors flex items-center space-x-2 ${
|
||||
activeTab === 'transfers'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
<span>My Transfers</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('files')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors flex items-center space-x-2 ${
|
||||
activeTab === 'files'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<Files className="w-4 h-4" />
|
||||
<span>File Manager</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === 'send' && (
|
||||
<SendPage />
|
||||
)}
|
||||
{activeTab === 'transfers' && <MyTransfers />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,18 @@
|
||||
// lib/auth.ts (server-safe only — no 'use client' here)
|
||||
import GoogleProvider from 'next-auth/providers/google'
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { NextAuthOptions } from 'next-auth'
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: PrismaAdapter(prisma),
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: 'database',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import path from 'path'
|
||||
|
||||
export const UPLOAD_DIR = path.join(process.cwd(), process.env.UPLOAD_DIR || './uploads/files')
|
||||
@@ -0,0 +1,99 @@
|
||||
export async function encryptFile(file: File, password: string) {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16))
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
|
||||
const keyMaterial = await getKeyMaterial(password)
|
||||
const key = await deriveKey(keyMaterial, salt)
|
||||
|
||||
const fileBuffer = await file.arrayBuffer()
|
||||
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, fileBuffer)
|
||||
|
||||
const encryptedBytes = new Uint8Array(encrypted)
|
||||
|
||||
// Combine salt + iv + encrypted data
|
||||
const combined = new Uint8Array(salt.length + iv.length + encryptedBytes.length)
|
||||
combined.set(salt, 0)
|
||||
combined.set(iv, salt.length)
|
||||
combined.set(encryptedBytes, salt.length + iv.length)
|
||||
|
||||
return {
|
||||
encryptedBlob: new Blob([combined], { type: 'application/octet-stream' }),
|
||||
metadata: {
|
||||
salt: Array.from(salt),
|
||||
iv: Array.from(iv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getKeyMaterial(password: string) {
|
||||
const enc = new TextEncoder()
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(password),
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveKey']
|
||||
)
|
||||
}
|
||||
|
||||
// export async function decryptBlob(blob: Blob, password: string): Promise<Blob> {
|
||||
// const combined = new Uint8Array(await blob.arrayBuffer())
|
||||
|
||||
// const salt = combined.slice(0, 16)
|
||||
// const iv = combined.slice(16, 28)
|
||||
// const data = combined.slice(28)
|
||||
|
||||
// const keyMaterial = await getKeyMaterial(password)
|
||||
// const key = await deriveKey(keyMaterial, salt)
|
||||
|
||||
// const decrypted = await crypto.subtle.decrypt(
|
||||
// { name: 'AES-GCM', iv },
|
||||
// key,
|
||||
// data
|
||||
// )
|
||||
|
||||
// return new Blob([decrypted])
|
||||
// }
|
||||
|
||||
|
||||
async function deriveKey(keyMaterial: CryptoKey, salt: Uint8Array) {
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
}
|
||||
|
||||
export async function encryptBlob(blob: Blob, password: string) {
|
||||
const pwUtf8 = new TextEncoder().encode(password)
|
||||
const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8)
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
|
||||
const key = await crypto.subtle.importKey('raw', pwHash, 'AES-GCM', false, ['encrypt'])
|
||||
const content = new Uint8Array(await blob.arrayBuffer())
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, content)
|
||||
const encryptedBlob = new Blob([iv, new Uint8Array(encrypted)])
|
||||
|
||||
return { encryptedBlob }
|
||||
}
|
||||
|
||||
export async function decryptBlob(encryptedBlob: Blob, password: string) {
|
||||
const data = new Uint8Array(await encryptedBlob.arrayBuffer())
|
||||
const iv = data.slice(0, 12)
|
||||
const encrypted = data.slice(12)
|
||||
|
||||
const pwUtf8 = new TextEncoder().encode(password)
|
||||
const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8)
|
||||
const key = await crypto.subtle.importKey('raw', pwHash, 'AES-GCM', false, ['decrypt'])
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted)
|
||||
return new Blob([decrypted])
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Mailjet from 'node-mailjet'
|
||||
|
||||
const mailjet = Mailjet.apiConnect(
|
||||
process.env.MJ_API_KEY || '',
|
||||
process.env.MJ_SECRET_KEY || ''
|
||||
)
|
||||
|
||||
export async function sendMailjetEmail({ to, subject, text }: {
|
||||
to: string,
|
||||
subject: string,
|
||||
text: string
|
||||
}) {
|
||||
await mailjet.post('send', { version: 'v3.1' }).request({
|
||||
Messages: [{
|
||||
From: {
|
||||
Email: 'dev@twotalesdev.com',
|
||||
Name: 'TransferTribe'
|
||||
},
|
||||
To: [{ Email: to }],
|
||||
Subject: subject,
|
||||
TextPart: text
|
||||
}]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// src/lib/prisma.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { TransferStatus } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient(); // Changed to a named export
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
|
||||
export const getTransfer = async (id: string) => {
|
||||
const transfer = await prisma.transfer.findUnique({
|
||||
where: { id: Number(id) },
|
||||
include: {
|
||||
files: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!transfer) return null;
|
||||
|
||||
// Check if expired and update status
|
||||
if (new Date() > transfer.expiresAt && transfer.status === TransferStatus.ACTIVE) {
|
||||
await prisma.transfer.update({
|
||||
where: { id: Number(id) },
|
||||
data: { status: TransferStatus.EXPIRED },
|
||||
});
|
||||
transfer.status = TransferStatus.EXPIRED;
|
||||
}
|
||||
|
||||
return transfer;
|
||||
};
|
||||
|
||||
export const deleteTransfer = async (id: string) => {
|
||||
return await prisma.transfer.update({
|
||||
where: { id: Number(id) },
|
||||
data: { status: TransferStatus.DELETED },
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
// Server-side encryption for chunked uploads.
|
||||
//
|
||||
// FILE FORMAT (v2)
|
||||
//
|
||||
// header: magic (8 bytes, "TTENC2\0\0") | salt (16 bytes)
|
||||
// frames: [ iv (12) | payloadLen uint32BE (4) | ciphertext || authTag ] *
|
||||
//
|
||||
// Every chunk is an independent AES-256-GCM message with its OWN random IV and
|
||||
// its OWN auth tag stored alongside it. This matters: an earlier version of
|
||||
// this file encrypted every chunk under one shared session IV and discarded the
|
||||
// tags, which reuses the GCM keystream (XORing two ciphertexts recovers the
|
||||
// plaintext without the key) and made the stored file undecryptable.
|
||||
//
|
||||
// Never encrypt two chunks under the same (key, iv) pair.
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
export const MAGIC = Buffer.from('TTENC2\0\0', 'binary'); // 8 bytes
|
||||
export const SALT_LENGTH = 16;
|
||||
export const IV_LENGTH = 12;
|
||||
export const AUTH_TAG_LENGTH = 16;
|
||||
export const LENGTH_PREFIX_BYTES = 4;
|
||||
export const HEADER_LENGTH = MAGIC.length + SALT_LENGTH;
|
||||
export const FRAME_PREFIX_LENGTH = IV_LENGTH + LENGTH_PREFIX_BYTES;
|
||||
|
||||
const PBKDF2_ITERATIONS = 100000;
|
||||
const KEY_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Derive an encryption key from a password using PBKDF2.
|
||||
*/
|
||||
export async function deriveKeyFromPassword(password: string, salt: Buffer): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.pbkdf2(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256', (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derivedKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the file header. Written once, before any frame.
|
||||
*/
|
||||
export function buildHeader(salt: Buffer): Buffer {
|
||||
if (salt.length !== SALT_LENGTH) {
|
||||
throw new Error(`Salt must be ${SALT_LENGTH} bytes`);
|
||||
}
|
||||
return Buffer.concat([MAGIC, salt]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the file header, returning the salt.
|
||||
* Throws if the magic does not match (e.g. a file written by the old format).
|
||||
*/
|
||||
export function parseHeader(header: Buffer): { salt: Buffer } {
|
||||
if (header.length < HEADER_LENGTH || !header.subarray(0, MAGIC.length).equals(MAGIC)) {
|
||||
throw new Error('UNSUPPORTED_FORMAT');
|
||||
}
|
||||
return { salt: header.subarray(MAGIC.length, HEADER_LENGTH) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt one chunk into a self-contained frame.
|
||||
* A fresh random IV is generated per call — callers must not supply one.
|
||||
*/
|
||||
export function encryptChunkFrame(plaintext: Buffer, key: Buffer): Buffer {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
const lengthPrefix = Buffer.alloc(LENGTH_PREFIX_BYTES);
|
||||
lengthPrefix.writeUInt32BE(ciphertext.length + authTag.length, 0);
|
||||
|
||||
return Buffer.concat([iv, lengthPrefix, ciphertext, authTag]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt one frame payload (ciphertext || authTag) using its frame IV.
|
||||
* Throws if the tag does not verify — i.e. wrong password or tampered data.
|
||||
*/
|
||||
export function decryptChunkPayload(payload: Buffer, key: Buffer, iv: Buffer): Buffer {
|
||||
if (payload.length < AUTH_TAG_LENGTH) {
|
||||
throw new Error('Malformed frame: payload shorter than auth tag');
|
||||
}
|
||||
const ciphertext = payload.subarray(0, payload.length - AUTH_TAG_LENGTH);
|
||||
const authTag = payload.subarray(payload.length - AUTH_TAG_LENGTH);
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an encrypted file and emit decrypted chunks one frame at a time, so a
|
||||
* multi-gigabyte transfer never has to be held in memory.
|
||||
*
|
||||
* Yields plaintext buffers in order. Throws 'UNSUPPORTED_FORMAT' for files
|
||||
* written by the pre-v2 scheme, and a GCM auth error for a wrong password.
|
||||
*/
|
||||
export async function* decryptFileStream(
|
||||
filePath: string,
|
||||
password: string
|
||||
): AsyncGenerator<Buffer> {
|
||||
const { promises: fsPromises } = await import('fs');
|
||||
const handle = await fsPromises.open(filePath, 'r');
|
||||
|
||||
try {
|
||||
const header = Buffer.alloc(HEADER_LENGTH);
|
||||
const { bytesRead } = await handle.read(header, 0, HEADER_LENGTH, 0);
|
||||
if (bytesRead < HEADER_LENGTH) {
|
||||
throw new Error('UNSUPPORTED_FORMAT');
|
||||
}
|
||||
|
||||
const { salt } = parseHeader(header);
|
||||
const key = await deriveKeyFromPassword(password, salt);
|
||||
|
||||
let offset = HEADER_LENGTH;
|
||||
const framePrefix = Buffer.alloc(FRAME_PREFIX_LENGTH);
|
||||
|
||||
for (;;) {
|
||||
const { bytesRead: prefixRead } = await handle.read(
|
||||
framePrefix, 0, FRAME_PREFIX_LENGTH, offset
|
||||
);
|
||||
if (prefixRead === 0) break; // clean end of file
|
||||
if (prefixRead < FRAME_PREFIX_LENGTH) {
|
||||
throw new Error('Malformed frame: truncated header');
|
||||
}
|
||||
offset += FRAME_PREFIX_LENGTH;
|
||||
|
||||
const iv = Buffer.from(framePrefix.subarray(0, IV_LENGTH));
|
||||
const payloadLength = framePrefix.readUInt32BE(IV_LENGTH);
|
||||
|
||||
const payload = Buffer.alloc(payloadLength);
|
||||
const { bytesRead: payloadRead } = await handle.read(payload, 0, payloadLength, offset);
|
||||
if (payloadRead < payloadLength) {
|
||||
throw new Error('Malformed frame: truncated payload');
|
||||
}
|
||||
offset += payloadLength;
|
||||
|
||||
yield decryptChunkPayload(payload, key, iv);
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const PLAN_CONFIG = {
|
||||
free: {
|
||||
maxFileSize: 2 * 1024 * 1024 * 1024, // 2GB max file size
|
||||
maxTransfersPerMonth: 10,
|
||||
maxTransferSizePerMonth: 2 * 1024 * 1024 * 1024, // (if you want to track total sent size per month, keep this)
|
||||
maxExpiryMs: 7 * 24 * 60 * 60 * 1000, // could be longer, say 7 days expiry?
|
||||
},
|
||||
rookie: {
|
||||
maxFileSize: Infinity, // no max file size per transfer
|
||||
maxTransfersPerMonth: Infinity, // unlimited transfers
|
||||
maxTransferSizePerMonth: 30 * 1024 * 1024 * 1024, // 30GB per month limit total
|
||||
maxExpiryMs: 7 * 24 * 60 * 60 * 1000, // 7 days expiry
|
||||
},
|
||||
pro: {
|
||||
maxFileSize: Infinity,
|
||||
maxTransfersPerMonth: Infinity,
|
||||
maxTransferSizePerMonth: 1 * 1024 * 1024 * 1024 * 1024, // 1TB per month
|
||||
maxExpiryMs: 14 * 24 * 60 * 60 * 1000, // Pro users get longer expiry? Your call
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function formatTimeRemaining(expiresAt: Date): string {
|
||||
const now = new Date();
|
||||
const diff = expiresAt.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) return 'Expired';
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} day${days > 1 ? 's' : ''} remaining`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ${minutes}m remaining`;
|
||||
} else {
|
||||
return `${minutes}m remaining`;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
Reference in New Issue
Block a user