Compare commits
3 Commits
95a4bc74fa
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b56ec31ed | |||
| 2d0c33a45b | |||
| 2e688c8e52 |
@@ -0,0 +1,27 @@
|
||||
# Copy to .env and fill in. Never commit the real .env.
|
||||
|
||||
# Postgres connection string
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/transfertribe"
|
||||
|
||||
# Where encrypted transfer payloads are written, relative to the project root.
|
||||
UPLOAD_DIR="./uploads/files"
|
||||
|
||||
# Mailjet credentials for transfer notification emails
|
||||
MJ_API_KEY=""
|
||||
MJ_SECRET_KEY=""
|
||||
|
||||
# Public base URL, used to build download links in emails
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET=""
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
# Shared secret for POST /api/cron/cleanup, which deletes payloads belonging to
|
||||
# expired and soft-deleted transfers. Without it that endpoint refuses to run
|
||||
# and nothing ever reclaims disk. Generate with: openssl rand -hex 32
|
||||
CRON_SECRET=""
|
||||
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -39,3 +40,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;
|
||||
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
eslint: {
|
||||
// Generated Prisma client is excluded in eslint.config.mjs; app code is linted.
|
||||
dirs: ["src", "pages"],
|
||||
},
|
||||
};
|
||||
|
||||
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,477 @@
|
||||
// pages/api/chunk-upload-v2.ts
|
||||
// Chunked upload with server-side encryption.
|
||||
//
|
||||
// Raw chunks are uploaded, encrypted individually on arrival, and reassembled
|
||||
// into one encrypted file per uploaded file. See lib/server-encryption.ts for
|
||||
// the on-disk format.
|
||||
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { IncomingForm, Fields, Files } 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 UploadFile {
|
||||
name: string;
|
||||
totalChunks: number;
|
||||
receivedChunks: Set<number>;
|
||||
receivedBytes: number;
|
||||
}
|
||||
|
||||
interface UploadSession {
|
||||
uploadId: string;
|
||||
sender: string;
|
||||
recipient: string;
|
||||
password: string;
|
||||
message?: string;
|
||||
filenames?: string;
|
||||
fileCount: number;
|
||||
/** Keyed by fileIndex. */
|
||||
files: Map<number, UploadFile>;
|
||||
createdAt: Date;
|
||||
// One salt/key per transfer. Sharing a key across files is safe because every
|
||||
// chunk frame carries its own unique IV.
|
||||
salt: Buffer;
|
||||
encryptionKey: Buffer;
|
||||
maxBytes: number;
|
||||
totalReceivedBytes: number;
|
||||
}
|
||||
|
||||
// NOTE: in-process state, matching the local-disk storage model. Running more
|
||||
// than one instance requires a shared store (Redis) plus shared object storage.
|
||||
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, force: true }).catch((err) =>
|
||||
console.warn(`Failed to clean abandoned upload ${uploadId}:`, err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
const firstValue = (value: string | string[] | undefined): string | undefined =>
|
||||
Array.isArray(value) ? value[0] : value;
|
||||
|
||||
const fileChunkDir = (uploadId: string, fileIndex: number) =>
|
||||
path.join(UPLOAD_DIR, '.tmp', uploadId, `f${fileIndex}`);
|
||||
|
||||
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
|
||||
) {
|
||||
await fsPromises.mkdir(path.join(UPLOAD_DIR, '.tmp'), { recursive: true }).catch((err) => {
|
||||
console.error('Failed to create temp directory:', err);
|
||||
});
|
||||
|
||||
const form = new IncomingForm({
|
||||
uploadDir: path.join(UPLOAD_DIR, '.tmp'),
|
||||
keepExtensions: true,
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
form.parse(req, async (err: Error | null, fields: Fields, files: Files) => {
|
||||
if (err) {
|
||||
res.status(400).json({ success: false, message: 'Parse error: ' + err.message });
|
||||
return resolve();
|
||||
}
|
||||
|
||||
try {
|
||||
const chunkFile = Array.isArray(files.chunk) ? files.chunk[0] : files.chunk;
|
||||
const uploadId = firstValue(fields.uploadId);
|
||||
const chunkIndex = parseInt(firstValue(fields.chunkIndex) ?? '', 10);
|
||||
const fileIndex = parseInt(firstValue(fields.fileIndex) ?? '0', 10);
|
||||
const fileChunks = parseInt(firstValue(fields.fileChunks) ?? '', 10);
|
||||
const fileCount = parseInt(firstValue(fields.fileCount) ?? '1', 10);
|
||||
const fileName = firstValue(fields.fileName);
|
||||
|
||||
if (
|
||||
!chunkFile ||
|
||||
!uploadId ||
|
||||
Number.isNaN(chunkIndex) ||
|
||||
Number.isNaN(fileIndex) ||
|
||||
Number.isNaN(fileChunks) ||
|
||||
fileIndex < 0 ||
|
||||
chunkIndex < 0 ||
|
||||
chunkIndex >= fileChunks
|
||||
) {
|
||||
res.status(400).json({ success: false, message: 'Missing or invalid chunk metadata' });
|
||||
return resolve();
|
||||
}
|
||||
|
||||
let session = uploadSessions.get(uploadId);
|
||||
if (session && session.sender !== sessionEmail) {
|
||||
res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
return resolve();
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
const recipient = firstValue(fields.email);
|
||||
const password = firstValue(fields.password);
|
||||
const message = firstValue(fields.message);
|
||||
const filenames = firstValue(fields.filenames);
|
||||
|
||||
if (!recipient) {
|
||||
res.status(400).json({ success: false, message: 'Missing required fields' });
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
res.status(400).json({ success: false, message: 'A password is required' });
|
||||
return resolve();
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: sessionEmail } });
|
||||
if (!user) {
|
||||
res.status(404).json({ success: false, message: 'User not found' });
|
||||
return resolve();
|
||||
}
|
||||
|
||||
const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro';
|
||||
const salt = crypto.randomBytes(16);
|
||||
|
||||
session = {
|
||||
uploadId,
|
||||
sender: sessionEmail,
|
||||
recipient,
|
||||
password,
|
||||
message,
|
||||
filenames,
|
||||
fileCount: Number.isNaN(fileCount) ? 1 : fileCount,
|
||||
files: new Map(),
|
||||
createdAt: new Date(),
|
||||
salt,
|
||||
encryptionKey: await deriveKeyFromPassword(password, salt),
|
||||
maxBytes: PLAN_CONFIG[plan].maxFileSize,
|
||||
totalReceivedBytes: 0,
|
||||
};
|
||||
|
||||
uploadSessions.set(uploadId, session);
|
||||
}
|
||||
|
||||
let entry = session.files.get(fileIndex);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
name: fileName || `file-${fileIndex}`,
|
||||
totalChunks: fileChunks,
|
||||
receivedChunks: new Set(),
|
||||
receivedBytes: 0,
|
||||
};
|
||||
session.files.set(fileIndex, entry);
|
||||
}
|
||||
|
||||
const chunkData = await fsPromises.readFile(chunkFile.filepath);
|
||||
await fsPromises.unlink(chunkFile.filepath).catch(() => {});
|
||||
|
||||
// Enforce the plan cap against bytes actually received, not the
|
||||
// client-declared size.
|
||||
const isNewChunk = !entry.receivedChunks.has(chunkIndex);
|
||||
if (
|
||||
isNewChunk &&
|
||||
session.maxBytes !== Infinity &&
|
||||
session.totalReceivedBytes + chunkData.length > session.maxBytes
|
||||
) {
|
||||
res.status(413).json({
|
||||
success: false,
|
||||
message: 'Transfer exceeds the maximum size for your plan',
|
||||
});
|
||||
return resolve();
|
||||
}
|
||||
|
||||
const dir = fileChunkDir(uploadId, fileIndex);
|
||||
await fsPromises.mkdir(dir, { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
path.join(dir, `chunk-${chunkIndex}.enc`),
|
||||
encryptChunkFrame(chunkData, session.encryptionKey)
|
||||
);
|
||||
|
||||
if (isNewChunk) {
|
||||
entry.receivedBytes += chunkData.length;
|
||||
session.totalReceivedBytes += chunkData.length;
|
||||
}
|
||||
entry.receivedChunks.add(chunkIndex);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
uploadId,
|
||||
fileIndex,
|
||||
chunkIndex,
|
||||
receivedChunks: entry.receivedChunks.size,
|
||||
totalChunks: entry.totalChunks,
|
||||
});
|
||||
return resolve();
|
||||
} catch (error: unknown) {
|
||||
console.error('Chunk upload error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Upload error: ' + (error as Error).message,
|
||||
});
|
||||
return resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
const files = [...session.files.entries()].map(([fileIndex, entry]) => ({
|
||||
fileIndex,
|
||||
name: entry.name,
|
||||
receivedChunks: entry.receivedChunks.size,
|
||||
totalChunks: entry.totalChunks,
|
||||
isComplete: entry.receivedChunks.size === entry.totalChunks,
|
||||
}));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
uploadId,
|
||||
fileCount: session.fileCount,
|
||||
files,
|
||||
receivedBytes: session.totalReceivedBytes,
|
||||
isComplete:
|
||||
session.files.size === session.fileCount && files.every((f) => f.isComplete),
|
||||
});
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
// Every file must be present and whole before anything is assembled.
|
||||
if (session.files.size !== session.fileCount) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Missing files. Received ${session.files.size}/${session.fileCount}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [fileIndex, entry] of session.files) {
|
||||
if (entry.receivedChunks.size !== entry.totalChunks) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `File ${fileIndex} incomplete: ${entry.receivedChunks.size}/${entry.totalChunks} chunks`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const writtenPaths: string[] = [];
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { email: session.sender } });
|
||||
if (!user) {
|
||||
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 } },
|
||||
});
|
||||
|
||||
// Sizes are plaintext bytes received, not on-disk size, which is inflated
|
||||
// by the format header and each frame's IV and auth tag.
|
||||
const totalPlaintext = session.totalReceivedBytes;
|
||||
const sentThisMonth = transfersThisMonth.reduce(
|
||||
(acc, t) => acc + BigInt(t.totalSize),
|
||||
BigInt(0)
|
||||
);
|
||||
|
||||
if (
|
||||
(limits.maxTransfersPerMonth !== Infinity &&
|
||||
transfersThisMonth.length >= limits.maxTransfersPerMonth) ||
|
||||
(limits.maxTransferSizePerMonth !== Infinity &&
|
||||
sentThisMonth + BigInt(totalPlaintext) > BigInt(limits.maxTransferSizePerMonth))
|
||||
) {
|
||||
return res.status(429).json({ success: false, message: 'Plan limits exceeded' });
|
||||
}
|
||||
|
||||
// Assemble each file into its own encrypted payload.
|
||||
const assembled: { name: string; path: string; size: number }[] = [];
|
||||
|
||||
for (const [fileIndex, entry] of [...session.files.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const dir = fileChunkDir(uploadId, fileIndex);
|
||||
const finalPath = path.join(UPLOAD_DIR, uuidv4() + '.enc');
|
||||
writtenPaths.push(finalPath);
|
||||
|
||||
const writeStream = fs.createWriteStream(finalPath);
|
||||
// Honour backpressure so a multi-GB reassembly flushes to disk rather
|
||||
// than queueing in the stream's internal buffer.
|
||||
const write = (buf: Buffer): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (writeStream.write(buf)) return resolve();
|
||||
writeStream.once('drain', resolve);
|
||||
writeStream.once('error', reject);
|
||||
});
|
||||
|
||||
await write(buildHeader(session.salt));
|
||||
|
||||
for (let i = 0; i < entry.totalChunks; i++) {
|
||||
const chunkPath = path.join(dir, `chunk-${i}.enc`);
|
||||
let chunkData: Buffer | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
chunkData = await fsPromises.readFile(chunkPath);
|
||||
break;
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
if (attempt < 2 && code === 'EACCES') {
|
||||
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 100));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A silently skipped chunk would corrupt the file, so fail loudly.
|
||||
if (!chunkData) {
|
||||
throw new Error(`Chunk ${i} of file ${fileIndex} missing during reassembly`);
|
||||
}
|
||||
|
||||
await write(chunkData);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.on('finish', resolve);
|
||||
writeStream.on('error', reject);
|
||||
writeStream.end();
|
||||
});
|
||||
|
||||
assembled.push({ name: entry.name, path: finalPath, size: entry.receivedBytes });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(session.password, 10);
|
||||
const expiresAt = new Date(Date.now() + limits.maxExpiryMs);
|
||||
|
||||
const transfer = await prisma.transfer.create({
|
||||
data: {
|
||||
senderEmail: session.sender,
|
||||
recipientEmail: session.recipient,
|
||||
passwordHash: hash,
|
||||
downloadUrl: uuidv4(),
|
||||
expiresAt,
|
||||
filenames: session.filenames || JSON.stringify(assembled.map((f) => f.name)),
|
||||
message: session.message || null,
|
||||
totalFiles: assembled.length,
|
||||
totalSize: totalPlaintext,
|
||||
userId: user.id,
|
||||
files: {
|
||||
create: assembled.map((f) => ({ name: f.name, path: f.path, size: f.size })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://transfertribe.com';
|
||||
const link = `${baseUrl}/download/${transfer.downloadUrl}`;
|
||||
const fileLine =
|
||||
assembled.length === 1 ? '1 file' : `${assembled.length} files`;
|
||||
|
||||
await sendMailjetEmail({
|
||||
to: session.recipient,
|
||||
subject: "You've received an encrypted file",
|
||||
text: `
|
||||
${session.sender} sent you ${fileLine} 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(),
|
||||
});
|
||||
|
||||
uploadSessions.delete(uploadId);
|
||||
await fsPromises
|
||||
.rm(path.join(UPLOAD_DIR, '.tmp', uploadId), { recursive: true, force: 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,
|
||||
files: assembled.length,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Chunk completion error:', error);
|
||||
// Don't leave half-assembled payloads behind on failure.
|
||||
await Promise.all(
|
||||
writtenPaths.map((p) => fsPromises.unlink(p).catch(() => {}))
|
||||
);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Completion error: ' + (error as Error).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,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
import { runCleanup } from '@/lib/cleanup'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
function timingSafeEquals(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a)
|
||||
const bufB = Buffer.from(b)
|
||||
if (bufA.length !== bufB.length) return false
|
||||
return crypto.timingSafeEqual(bufA, bufB)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes payloads for expired and soft-deleted transfers.
|
||||
*
|
||||
* Intended to be called on a schedule (cron, Vercel Cron, systemd timer):
|
||||
* curl -X POST -H "Authorization: Bearer $CRON_SECRET" https://host/api/cron/cleanup
|
||||
*
|
||||
* Fails closed: without CRON_SECRET set, the endpoint refuses to run rather
|
||||
* than exposing bulk deletion unauthenticated.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const secret = process.env.CRON_SECRET
|
||||
|
||||
if (!secret) {
|
||||
console.error('CRON_SECRET is not configured; refusing to run cleanup')
|
||||
return NextResponse.json({ error: 'Cleanup is not configured' }, { status: 503 })
|
||||
}
|
||||
|
||||
const header = req.headers.get('authorization') || ''
|
||||
const provided = header.startsWith('Bearer ') ? header.slice(7) : ''
|
||||
|
||||
if (!provided || !timingSafeEquals(provided, secret)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const report = await runCleanup()
|
||||
console.log('Cleanup report:', report)
|
||||
return NextResponse.json({ success: true, ...report })
|
||||
} catch (err) {
|
||||
console.error('Cleanup failed:', err)
|
||||
return NextResponse.json({ error: 'Cleanup failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -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,178 @@
|
||||
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'
|
||||
import { rateLimit, resetRateLimit, clientKey } from '@/lib/rate-limit'
|
||||
|
||||
// 10 password attempts per transfer, per client, per 15 minutes.
|
||||
const ATTEMPT_LIMIT = 10
|
||||
const ATTEMPT_WINDOW_MS = 15 * 60 * 1000
|
||||
|
||||
// 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 = ''
|
||||
let requestedFileId: number | undefined
|
||||
try {
|
||||
const body = await req.json()
|
||||
password = typeof body?.password === 'string' ? body.password : ''
|
||||
if (typeof body?.fileId === 'number' && Number.isInteger(body.fileId)) {
|
||||
requestedFileId = body.fileId
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return NextResponse.json({ error: 'Password required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const rateKey = `file:${id}:${clientKey(req.headers)}`
|
||||
const limit = rateLimit(rateKey, ATTEMPT_LIMIT, ATTEMPT_WINDOW_MS)
|
||||
if (!limit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many attempts. Please try again later.' },
|
||||
{ status: 429, headers: { 'Retry-After': String(limit.retryAfter) } }
|
||||
)
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
resetRateLimit(rateKey)
|
||||
|
||||
// Pick the requested file, scoped to this transfer so a file id from another
|
||||
// transfer cannot be fetched. Defaults to the first file.
|
||||
const file = requestedFileId === undefined
|
||||
? transfer.files[0]
|
||||
: transfer.files.find((f) => f.id === requestedFileId)
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'File not found in this transfer' }, { status: 404 })
|
||||
}
|
||||
|
||||
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: unknown) {
|
||||
await frames.return(undefined as never).catch(() => {})
|
||||
if (err instanceof Error && 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,82 @@
|
||||
// app/api/verify/route.ts
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { TransferStatus } from '@prisma/client'
|
||||
import { rateLimit, resetRateLimit, clientKey } from '@/lib/rate-limit'
|
||||
|
||||
// 10 password attempts per transfer, per client, per 15 minutes.
|
||||
const ATTEMPT_LIMIT = 10
|
||||
const ATTEMPT_WINDOW_MS = 15 * 60 * 1000
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
// Keyed before the DB lookup so throttling costs an attacker a request
|
||||
// regardless of whether the transfer exists.
|
||||
const key = `verify:${id}:${clientKey(req.headers)}`
|
||||
const limit = rateLimit(key, ATTEMPT_LIMIT, ATTEMPT_WINDOW_MS)
|
||||
if (!limit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Too many attempts. Please try again later.' },
|
||||
{ status: 429, headers: { 'Retry-After': String(limit.retryAfter) } }
|
||||
)
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
// Correct password: clear the counter so an earlier typo does not throttle.
|
||||
resetRateLimit(key)
|
||||
|
||||
// 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,245 @@
|
||||
'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 {
|
||||
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) {
|
||||
console.error(error);
|
||||
toast.error('Something went wrong');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderSignIn = async (provider: string) => {
|
||||
setLoadingProvider(provider);
|
||||
try {
|
||||
await signIn(provider, { callbackUrl: '/send' });
|
||||
} catch (error) {
|
||||
console.error(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,319 @@
|
||||
'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) {
|
||||
console.error(error);
|
||||
toast.error('Something went wrong');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderSignIn = async (provider: string) => {
|
||||
setLoadingProvider(provider);
|
||||
try {
|
||||
await signIn(provider, { callbackUrl: '/' });
|
||||
} catch (error) {
|
||||
console.error(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,537 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
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,
|
||||
Clock,
|
||||
Shield,
|
||||
User,
|
||||
MessageSquare,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
AlertCircle,
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { formatFileSize, formatTimeRemaining } from '@/lib/utils';
|
||||
import { saveResponseToDisk, DownloadCancelled } from '@/lib/download';
|
||||
|
||||
interface TransferFile {
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface TransferInfo {
|
||||
senderEmail: string;
|
||||
expiresAt: string;
|
||||
// Pre-authentication shape
|
||||
fileCount?: number;
|
||||
totalSize: number;
|
||||
requiresPassword?: boolean;
|
||||
// Post-verification shape
|
||||
message?: string | null;
|
||||
totalFiles?: number;
|
||||
files?: TransferFile[];
|
||||
}
|
||||
|
||||
export default function DownloadPage() {
|
||||
const { id } = useParams() as { id: string };
|
||||
|
||||
const [transfer, setTransfer] = useState<TransferInfo | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [downloadingFiles, setDownloadingFiles] = useState<Set<number>>(
|
||||
new Set()
|
||||
);
|
||||
const [downloadingAll, setDownloadingAll] = useState(false);
|
||||
const [downloadedFiles, setDownloadedFiles] = useState<Set<number>>(
|
||||
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 downloadFile = async (file: TransferFile) => {
|
||||
setDownloadingFiles((prev) => new Set(prev).add(file.id));
|
||||
setStatus(`Downloading ${file.name}...`);
|
||||
|
||||
try {
|
||||
// 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, fileId: file.id }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(err?.error || 'File not found or invalid password.');
|
||||
}
|
||||
|
||||
// Streams straight to disk where supported, so a multi-GB file is never
|
||||
// held in browser memory.
|
||||
await saveResponseToDisk(res, file.name.replace(/\.enc$/, '') || 'file');
|
||||
|
||||
setDownloadedFiles((prev) => new Set(prev).add(file.id));
|
||||
setStatus('');
|
||||
toast.success(`${file.name} downloaded`);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DownloadCancelled) {
|
||||
setStatus('');
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
setStatus(`Download failed: ${detail}`);
|
||||
toast.error(detail);
|
||||
} finally {
|
||||
setDownloadingFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(file.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecrypt = async () => {
|
||||
if (!transfer?.files?.length) return;
|
||||
setDownloadingAll(true);
|
||||
try {
|
||||
// Sequential: parallel downloads of multi-GB files would compete for
|
||||
// bandwidth and memory, and each needs its own save prompt anyway.
|
||||
for (const file of transfer.files) {
|
||||
await downloadFile(file);
|
||||
}
|
||||
} finally {
|
||||
setDownloadingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
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: number) => downloadingFiles.has(fileId);
|
||||
const isFileDownloaded = (fileId: number) => 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.senderEmail}
|
||||
</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 ?? 0}{' '}
|
||||
{transfer.files?.length === 1 ? 'file' : '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 {formatTimeRemaining(new Date(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.files?.map((file) => (
|
||||
<Card
|
||||
key={file.id}
|
||||
className="border-gray-800 bg-gray-900/60 backdrop-blur-xl hover:bg-gray-900/80 transition-all duration-200"
|
||||
>
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium text-white truncate">{file.name}</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => downloadFile(file)}
|
||||
disabled={isFileDownloading(file.id) || downloadingAll}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isFileDownloading(file.id) ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-current border-t-transparent mr-2" />
|
||||
Downloading
|
||||
</>
|
||||
) : isFileDownloaded(file.id) ? (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download again
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<p className="mt-4 text-sm text-gray-400 text-center">{status}</p>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
+266
-96
@@ -1,103 +1,273 @@
|
||||
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,
|
||||
Mail,
|
||||
Star
|
||||
} 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,307 @@
|
||||
'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,
|
||||
Shield,
|
||||
Users,
|
||||
Mail
|
||||
} from 'lucide-react';
|
||||
|
||||
interface PricingTier {
|
||||
name: string;
|
||||
price: string;
|
||||
period: string;
|
||||
description: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
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) => {
|
||||
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,259 @@
|
||||
'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 } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Send,
|
||||
History,
|
||||
Files,
|
||||
Download,
|
||||
Clock,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [activeTab, setActiveTab] = useState<'send' | 'transfers' | 'files'>(
|
||||
'send'
|
||||
);
|
||||
const { data: session } = useSession();
|
||||
const userName = session?.user?.name || 'friend';
|
||||
|
||||
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, 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,318 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } 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,
|
||||
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 = useCallback(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) {
|
||||
console.error(error);
|
||||
toast.error('Failed to fetch transfers');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [session?.user?.email]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTransfers();
|
||||
}, [fetchTransfers]);
|
||||
|
||||
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) {
|
||||
console.error(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) {
|
||||
console.error(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,416 @@
|
||||
'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 [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);
|
||||
}
|
||||
};
|
||||
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');
|
||||
|
||||
// Chunk counts for every selected file, so progress spans the whole
|
||||
// transfer rather than just the first file.
|
||||
const perFileChunks = files.map((f) => Math.max(1, Math.ceil(f.size / CHUNK_SIZE)));
|
||||
const totalChunksCount = perFileChunks.reduce((a, b) => a + b, 0);
|
||||
setTotalChunks(totalChunksCount);
|
||||
|
||||
// Generate upload ID
|
||||
const uploadId = crypto.randomUUID();
|
||||
|
||||
// Upload raw chunks - server will encrypt each chunk
|
||||
setUploadStep('Uploading');
|
||||
let uploadedChunks = 0;
|
||||
|
||||
for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
|
||||
const file = files[fileIndex];
|
||||
const fileChunks = perFileChunks[fileIndex];
|
||||
|
||||
for (let i = 0; i < fileChunks; i++) {
|
||||
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('fileIndex', fileIndex.toString());
|
||||
formData.append('chunkIndex', i.toString());
|
||||
formData.append('fileChunks', fileChunks.toString());
|
||||
formData.append('fileCount', files.length.toString());
|
||||
formData.append('fileName', file.name);
|
||||
|
||||
// Transfer-wide metadata travels with the very first chunk only.
|
||||
if (fileIndex === 0 && i === 0) {
|
||||
formData.append('email', recipient);
|
||||
formData.append('password', password);
|
||||
formData.append('filenames', JSON.stringify(files.map((f) => f.name)));
|
||||
formData.append('message', message);
|
||||
}
|
||||
|
||||
const uploadRes = await fetch('/api/chunk-upload-v2', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
const error = await uploadRes.json().catch(() => ({}));
|
||||
throw new Error(error.message || `Chunk ${i} of ${file.name} failed`);
|
||||
}
|
||||
|
||||
uploadedChunks++;
|
||||
setCurrentChunk(uploadedChunks);
|
||||
setUploadProgress(Math.round((uploadedChunks / totalChunksCount) * 100));
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize upload
|
||||
setUploadStep('Finalizing');
|
||||
const finalRes = await fetch(`/api/chunk-upload-v2?uploadId=${uploadId}`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
|
||||
if (!finalRes.ok) {
|
||||
const error = await finalRes.json().catch(() => ({}));
|
||||
throw new Error(error.message || 'Failed to finalize upload');
|
||||
}
|
||||
|
||||
setUploadStep('');
|
||||
setUploadMessage('Success! 🎉');
|
||||
toast.success('Your transfer has been sent successfully!');
|
||||
setFiles([]);
|
||||
setRecipient('');
|
||||
setPassword('');
|
||||
setMessage('');
|
||||
setUploadProgress(100);
|
||||
setTimeout(() => setUploadMessage(''), 3000);
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
setUploadStep('');
|
||||
setUploadMessage('Error: ' + (err instanceof Error ? err.message : String(err)));
|
||||
}
|
||||
};
|
||||
|
||||
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,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,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,106 @@
|
||||
// Reclaims disk for transfers that are past their expiry or soft-deleted.
|
||||
//
|
||||
// Nothing previously removed payloads from UPLOAD_DIR, so every transfer ever
|
||||
// sent stayed on disk indefinitely regardless of expiresAt.
|
||||
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { UPLOAD_DIR } from '@/lib/config'
|
||||
import { TransferStatus } from '@prisma/client'
|
||||
|
||||
export interface CleanupReport {
|
||||
markedExpired: number
|
||||
filesDeleted: number
|
||||
bytesReclaimed: number
|
||||
staleTempDirs: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
/** Abandoned chunk directories older than this are removed. */
|
||||
const TEMP_DIR_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Refuse to unlink anything that does not live under UPLOAD_DIR. Paths come
|
||||
* from the database, so this is defence against a corrupted or tampered row
|
||||
* turning cleanup into arbitrary file deletion.
|
||||
*/
|
||||
function isInsideUploadDir(target: string): boolean {
|
||||
const relative = path.relative(UPLOAD_DIR, path.resolve(target))
|
||||
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
|
||||
}
|
||||
|
||||
export async function runCleanup(): Promise<CleanupReport> {
|
||||
const report: CleanupReport = {
|
||||
markedExpired: 0,
|
||||
filesDeleted: 0,
|
||||
bytesReclaimed: 0,
|
||||
staleTempDirs: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// 1. Flip lapsed ACTIVE transfers to EXPIRED.
|
||||
const marked = await prisma.transfer.updateMany({
|
||||
where: { status: TransferStatus.ACTIVE, expiresAt: { lte: now } },
|
||||
data: { status: TransferStatus.EXPIRED },
|
||||
})
|
||||
report.markedExpired = marked.count
|
||||
|
||||
// 2. Delete payloads for transfers that are no longer downloadable. The rows
|
||||
// are kept so senders retain their history; only the bytes go.
|
||||
const reclaimable = await prisma.transfer.findMany({
|
||||
where: { status: { in: [TransferStatus.EXPIRED, TransferStatus.DELETED] } },
|
||||
include: { files: true },
|
||||
})
|
||||
|
||||
for (const transfer of reclaimable) {
|
||||
for (const file of transfer.files) {
|
||||
if (!file.path) continue
|
||||
|
||||
if (!isInsideUploadDir(file.path)) {
|
||||
report.errors.push(`Refusing to delete path outside upload dir: ${file.path}`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(file.path)
|
||||
await fs.unlink(file.path)
|
||||
report.filesDeleted += 1
|
||||
report.bytesReclaimed += stat.size
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException)?.code
|
||||
// Already gone is the expected steady state on repeat runs.
|
||||
if (code !== 'ENOENT') {
|
||||
report.errors.push(`${file.path}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Drop abandoned chunk directories from uploads that never finished.
|
||||
const tempRoot = path.join(UPLOAD_DIR, '.tmp')
|
||||
try {
|
||||
const entries = await fs.readdir(tempRoot, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const dir = path.join(tempRoot, entry.name)
|
||||
try {
|
||||
const stat = await fs.stat(dir)
|
||||
if (now.getTime() - stat.mtimeMs > TEMP_DIR_MAX_AGE_MS) {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
report.staleTempDirs += 1
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
report.errors.push(`${dir}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
||||
report.errors.push(`temp sweep: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
@@ -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,113 @@
|
||||
// Client-side download helper.
|
||||
//
|
||||
// The naive approach — `await res.blob()` then createObjectURL — buffers the
|
||||
// entire decrypted file in memory before anything is written. That is fine for
|
||||
// small transfers and untenable for multi-gigabyte ones, which is exactly what
|
||||
// this app exists to move.
|
||||
//
|
||||
// Where the File System Access API is available (Chromium), the response body
|
||||
// is piped straight to a user-chosen file and peak memory stays at roughly one
|
||||
// chunk. Everywhere else we fall back to the blob path.
|
||||
|
||||
interface SaveFilePickerOptions {
|
||||
suggestedName?: string;
|
||||
types?: { description: string; accept: Record<string, string[]> }[];
|
||||
}
|
||||
|
||||
interface FileSystemWritable {
|
||||
write(data: BufferSource | Blob | string): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
abort?(reason?: unknown): Promise<void>;
|
||||
}
|
||||
|
||||
interface FileSystemFileHandleLike {
|
||||
createWritable(): Promise<FileSystemWritable>;
|
||||
}
|
||||
|
||||
type PickerWindow = Window & {
|
||||
showSaveFilePicker?: (
|
||||
options?: SaveFilePickerOptions
|
||||
) => Promise<FileSystemFileHandleLike>;
|
||||
};
|
||||
|
||||
export function supportsStreamingDownload(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof (window as PickerWindow).showSaveFilePicker === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/** Thrown when the user dismisses the save dialog. */
|
||||
export class DownloadCancelled extends Error {
|
||||
constructor() {
|
||||
super('Download cancelled');
|
||||
this.name = 'DownloadCancelled';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `response` to disk, streaming when the browser allows it.
|
||||
* `onProgress` receives bytes written so far.
|
||||
*/
|
||||
export async function saveResponseToDisk(
|
||||
response: Response,
|
||||
filename: string,
|
||||
onProgress?: (bytesWritten: number) => void
|
||||
): Promise<void> {
|
||||
const picker = (window as PickerWindow).showSaveFilePicker;
|
||||
|
||||
if (picker && response.body) {
|
||||
let handle: FileSystemFileHandleLike;
|
||||
try {
|
||||
handle = await picker({ suggestedName: filename });
|
||||
} catch (err) {
|
||||
// AbortError means the user closed the dialog; anything else is a real
|
||||
// failure worth falling back for.
|
||||
if ((err as DOMException)?.name === 'AbortError') {
|
||||
throw new DownloadCancelled();
|
||||
}
|
||||
return saveViaBlob(response, filename);
|
||||
}
|
||||
|
||||
const writable = await handle.createWritable();
|
||||
const reader = response.body.getReader();
|
||||
let written = 0;
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
await writable.write(value);
|
||||
written += value.byteLength;
|
||||
onProgress?.(written);
|
||||
}
|
||||
await writable.close();
|
||||
} catch (err) {
|
||||
await writable.abort?.(err).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return saveViaBlob(response, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for browsers without the File System Access API. Buffers the whole
|
||||
* body in memory, so very large transfers may fail here.
|
||||
*/
|
||||
async function saveViaBlob(response: Response, filename: string): Promise<void> {
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
} finally {
|
||||
// Give the browser a tick to start the download before revoking.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
}
|
||||
@@ -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,69 @@
|
||||
// Fixed-window rate limiter for password attempts.
|
||||
//
|
||||
// SCOPE: this is per-process, in-memory state. It is effective for a single
|
||||
// server instance, which is what the local-disk storage model already assumes.
|
||||
// Running several instances behind a load balancer would give each its own
|
||||
// counter, multiplying the effective limit by the instance count — moving to a
|
||||
// shared store (Redis) is a prerequisite for scaling out.
|
||||
|
||||
type Bucket = { count: number; resetAt: number }
|
||||
|
||||
const buckets = new Map<string, Bucket>()
|
||||
|
||||
// Bound the map so a flood of distinct keys cannot grow it without limit.
|
||||
const MAX_KEYS = 10000
|
||||
|
||||
function sweep(now: number) {
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (bucket.resetAt <= now) buckets.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean
|
||||
remaining: number
|
||||
/** Seconds until the window resets. Suitable for a Retry-After header. */
|
||||
retryAfter: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one unit against `key`. Returns whether the caller is under the limit.
|
||||
*/
|
||||
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
|
||||
const now = Date.now()
|
||||
const existing = buckets.get(key)
|
||||
|
||||
if (!existing || existing.resetAt <= now) {
|
||||
if (buckets.size >= MAX_KEYS) sweep(now)
|
||||
const resetAt = now + windowMs
|
||||
buckets.set(key, { count: 1, resetAt })
|
||||
return { allowed: true, remaining: limit - 1, retryAfter: Math.ceil(windowMs / 1000) }
|
||||
}
|
||||
|
||||
existing.count += 1
|
||||
const retryAfter = Math.max(1, Math.ceil((existing.resetAt - now) / 1000))
|
||||
|
||||
return {
|
||||
allowed: existing.count <= limit,
|
||||
remaining: Math.max(0, limit - existing.count),
|
||||
retryAfter,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the counter for a key — call after a successful authentication so a
|
||||
* legitimate user who mistyped a few times is not left throttled.
|
||||
*/
|
||||
export function resetRateLimit(key: string) {
|
||||
buckets.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort client identifier. x-forwarded-for is only trustworthy behind a
|
||||
* proxy that overwrites it; treat this as a speed bump, not an identity.
|
||||
*/
|
||||
export function clientKey(headers: Headers): string {
|
||||
const forwarded = headers.get('x-forwarded-for')
|
||||
if (forwarded) return forwarded.split(',')[0]!.trim()
|
||||
return headers.get('x-real-ip') || 'unknown'
|
||||
}
|
||||
@@ -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