This commit is contained in:
asabizanjo
2025-12-11 01:05:24 +00:00
parent c713d58f98
commit 423ce1bc6d
88 changed files with 4081 additions and 122 deletions

View File

@@ -1,36 +1,48 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Cloudflare R2 File Drive
## Getting Started
First, run the development server:
A simple FTP-style web UI built with Next.js App Router. Users can register/login (email + password), upload files or entire folders, list them, download via signed URLs, and delete. Files are stored in Cloudflare R2 (S3-compatible); metadata and users are stored in SQLite via Prisma.
## Setup
1) Install deps (already checked in `package-lock.json`):
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
npm install
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
2) Copy `env.example` to `.env.local` (or set env vars another way) and fill:
- `DATABASE_URL="file:./dev.db"` (default SQLite)
- `R2_ENDPOINT="https://<account-id>.r2.cloudflarestorage.com"`
- `R2_REGION="auto"`
- `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`
- `R2_BUCKET`
- `JWT_SECRET` (long random string)
- `PORT` / `NEXT_PORT` (defaults set to 4000 to avoid 3000)
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
3) Create DB & Prisma client:
```bash
npx prisma db push
npx prisma generate
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
4) Run dev server on port 4000:
```bash
npm run dev
# overrides: PORT=4500 npm run dev
```
## Learn More
5) Production:
```bash
npm run build
PORT=4000 npm run start
```
To learn more about Next.js, take a look at the following resources:
## Usage
- Register or log in at `/register` or `/login`.
- Dashboard at `/dashboard`:
- Upload files or folders (uses `webkitdirectory`; folder structure is preserved in keys).
- Files are stored under a user-specific prefix in R2; metadata kept in SQLite.
- Download uses short-lived signed URLs; delete removes from R2 and DB.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## Notes
- Middleware protects `/dashboard` and `/api/files/*`; auth cookie is HttpOnly JWT.
- To change ports, set `PORT`/`NEXT_PORT` or pass `-p` to scripts; defaults avoid 3000.
- R2 endpoint format: `https://<account-id>.r2.cloudflarestorage.com`.

141
app/(auth)/login/page.tsx Normal file
View File

@@ -0,0 +1,141 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
setLoading(false);
if (!res.ok) {
setError(data?.error ?? "Login failed");
return;
}
router.push("/dashboard");
};
return (
<div className="min-h-screen flex items-center justify-center px-4 py-8 relative overflow-hidden">
{/* Geometric Decorations */}
<div className="geo-accent geo-square top-20 left-10 opacity-50 animate-float" />
<div className="geo-accent geo-circle bottom-20 right-10 opacity-30" style={{ animationDelay: "2s" }} />
<div className="geo-accent geo-square bottom-40 left-1/4 opacity-20 hidden md:block" style={{ width: "40px", height: "40px" }} />
<div
className="geo-accent geo-line top-1/3 -right-10 opacity-40 hidden lg:block"
style={{ transform: "rotate(-45deg)" }}
/>
{/* Accent Glow */}
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] rounded-full opacity-10 pointer-events-none"
style={{ background: "radial-gradient(circle, var(--accent-lime) 0%, transparent 70%)" }}
/>
{/* Login Card */}
<div className="w-full max-w-md brutal-card p-8 md:p-10 relative z-10 animate-slide-up">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="w-3 h-3 bg-accent-lime" />
<div className="w-3 h-3 bg-accent-magenta" />
<div className="w-3 h-3 bg-accent-cyan" />
</div>
<h1 className="text-3xl md:text-4xl font-black text-text-primary tracking-tight">
Welcome back
</h1>
<p className="mt-2 text-text-secondary text-sm">
Log in to upload and download files.
</p>
</div>
{/* Form */}
<form className="space-y-6" onSubmit={onSubmit}>
<div className="animate-slide-up delay-100" style={{ opacity: 0, animationFillMode: "forwards" }}>
<label className="brutal-label">Email Address</label>
<input
type="email"
className="brutal-input"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="animate-slide-up delay-200" style={{ opacity: 0, animationFillMode: "forwards" }}>
<label className="brutal-label">Password</label>
<input
type="password"
className="brutal-input"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-danger-soft border-l-4 border-danger">
<svg className="w-5 h-5 text-danger flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-danger font-medium">{error}</p>
</div>
)}
<div className="animate-slide-up delay-300 pt-2" style={{ opacity: 0, animationFillMode: "forwards" }}>
<button
type="submit"
className="brutal-btn w-full"
disabled={loading}
>
{loading ? (
<>
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Signing in...
</>
) : (
<>
Sign In
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</>
)}
</button>
</div>
</form>
{/* Footer */}
<div className="mt-8 pt-6 border-t-2 border-bg-elevated">
<p className="text-sm text-text-secondary text-center">
No account yet?{" "}
<Link className="brutal-link" href="/register">
Create one
</Link>
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
export default function RegisterPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
setLoading(false);
if (!res.ok) {
setError(data?.error ?? "Registration failed");
return;
}
router.push("/dashboard");
};
return (
<div className="min-h-screen flex items-center justify-center px-4 py-8 relative overflow-hidden">
{/* Geometric Decorations */}
<div className="geo-accent geo-circle top-20 right-20 opacity-50 animate-float" />
<div className="geo-accent geo-square bottom-32 left-16 opacity-30" style={{ animationDelay: "3s" }} />
<div
className="geo-accent geo-line bottom-1/4 -left-10 opacity-40 hidden lg:block"
style={{ transform: "rotate(45deg)" }}
/>
<div
className="geo-accent top-1/4 right-1/4 opacity-20 hidden md:block"
style={{ width: "60px", height: "60px", border: "4px solid var(--accent-cyan)" }}
/>
{/* Accent Glow - Magenta variant */}
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] rounded-full opacity-10 pointer-events-none"
style={{ background: "radial-gradient(circle, var(--accent-magenta) 0%, transparent 70%)" }}
/>
{/* Register Card */}
<div className="w-full max-w-md brutal-card-magenta p-8 md:p-10 relative z-10 animate-slide-up" style={{ boxShadow: "8px 8px 0 var(--accent-magenta)" }}>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="w-3 h-3 bg-accent-magenta" />
<div className="w-3 h-3 bg-accent-cyan" />
<div className="w-3 h-3 bg-accent-lime" />
</div>
<h1 className="text-3xl md:text-4xl font-black text-text-primary tracking-tight">
Create account
</h1>
<p className="mt-2 text-text-secondary text-sm">
Register to start uploading your files.
</p>
</div>
{/* Form */}
<form className="space-y-6" onSubmit={onSubmit}>
<div className="animate-slide-up delay-100" style={{ opacity: 0, animationFillMode: "forwards" }}>
<label className="brutal-label">Email Address</label>
<input
type="email"
className="brutal-input"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
style={{ borderBottomColor: "var(--accent-magenta)" }}
/>
</div>
<div className="animate-slide-up delay-200" style={{ opacity: 0, animationFillMode: "forwards" }}>
<label className="brutal-label">Password</label>
<input
type="password"
className="brutal-input"
placeholder="Min. 6 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
style={{ borderBottomColor: "var(--accent-magenta)" }}
/>
<p className="mt-2 text-xs text-text-muted">
Use at least 6 characters for security
</p>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-danger-soft border-l-4 border-danger">
<svg className="w-5 h-5 text-danger flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-danger font-medium">{error}</p>
</div>
)}
<div className="animate-slide-up delay-300 pt-2" style={{ opacity: 0, animationFillMode: "forwards" }}>
<button
type="submit"
className="brutal-btn w-full"
style={{
background: "var(--accent-magenta)",
boxShadow: "4px 4px 0 var(--bg-dark)"
}}
disabled={loading}
>
{loading ? (
<>
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Creating account...
</>
) : (
<>
Create Account
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</>
)}
</button>
</div>
</form>
{/* Footer */}
<div className="mt-8 pt-6 border-t-2 border-bg-elevated">
<p className="text-sm text-text-secondary text-center">
Already have an account?{" "}
<Link
className="brutal-link"
href="/login"
style={{ color: "var(--accent-magenta)" }}
>
Log in
</Link>
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import {
createSessionToken,
setSessionCookie,
verifyPassword,
} from "@/lib/auth";
export async function POST(req: Request) {
const body = await req.json().catch(() => null);
const email = (body?.email as string | undefined)?.toLowerCase()?.trim();
const password = body?.password as string | undefined;
if (!email || !password) {
return NextResponse.json(
{ error: "Email and password are required." },
{ status: 400 }
);
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return NextResponse.json({ error: "Invalid credentials." }, { status: 401 });
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
return NextResponse.json({ error: "Invalid credentials." }, { status: 401 });
}
const token = await createSessionToken({ userId: user.id, email: user.email });
await setSessionCookie(token);
return NextResponse.json({
ok: true,
user: { id: user.id, email: user.email },
});
}

View File

@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { clearSessionCookie } from "@/lib/auth";
export async function POST() {
await clearSessionCookie();
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import {
createSessionToken,
hashPassword,
setSessionCookie,
} from "@/lib/auth";
export async function POST(req: Request) {
const body = await req.json().catch(() => null);
const email = (body?.email as string | undefined)?.toLowerCase()?.trim();
const password = body?.password as string | undefined;
if (!email || !password || password.length < 6) {
return NextResponse.json(
{ error: "Email and password (min 6 chars) are required." },
{ status: 400 }
);
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json(
{ error: "Email is already registered." },
{ status: 400 }
);
}
const passwordHash = await hashPassword(password);
const user = await prisma.user.create({
data: { email, passwordHash },
});
const token = await createSessionToken({ userId: user.id, email: user.email });
await setSessionCookie(token);
return NextResponse.json({
ok: true,
user: { id: user.id, email: user.email },
});
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
import { deleteFromR2 } from "@/lib/r2";
type Props = {
params: Promise<{ id: string }>;
};
export async function DELETE(_req: Request, { params }: Props) {
const session = await getSessionUser();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const file = await prisma.file.findFirst({
where: { id, userId: session.userId },
});
if (!file) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
await deleteFromR2(file.key);
await prisma.file.delete({ where: { id: file.id } });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
import { getSignedDownloadUrl } from "@/lib/r2";
type Props = {
params: Promise<{ id: string }>;
};
export async function GET(_req: Request, { params }: Props) {
const session = await getSessionUser();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const file = await prisma.file.findFirst({
where: { id, userId: session.userId },
});
if (!file) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
const url = await getSignedDownloadUrl(file.key);
return NextResponse.json({ url, name: file.name });
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
import { deleteFromR2 } from "@/lib/r2";
export async function DELETE() {
const session = await getSessionUser();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get all files for the user
const files = await prisma.file.findMany({
where: { userId: session.userId },
select: { id: true, key: true },
});
if (files.length === 0) {
return NextResponse.json({ ok: true, deleted: 0 });
}
// Delete all files from R2
const deletePromises = files.map((file) => deleteFromR2(file.key));
await Promise.all(deletePromises);
// Delete all file records from database
await prisma.file.deleteMany({
where: { userId: session.userId },
});
return NextResponse.json({ ok: true, deleted: files.length });
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
export async function GET() {
const session = await getSessionUser();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const files = await prisma.file.findMany({
where: { userId: session.userId },
orderBy: { createdAt: "desc" },
});
return NextResponse.json({
files: files.map((f) => ({
id: f.id,
key: f.key,
name: f.name,
relativePath: f.relativePath,
contentType: f.contentType,
sizeBytes: Number(f.sizeBytes),
createdAt: f.createdAt,
})),
});
}

View File

@@ -0,0 +1,81 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
import { uploadToR2 } from "@/lib/r2";
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 * 1024; // 10GB in bytes
function serializeFile(file: Awaited<ReturnType<typeof prisma.file.create>>) {
return {
id: file.id,
key: file.key,
name: file.name,
relativePath: file.relativePath,
contentType: file.contentType,
sizeBytes: Number(file.sizeBytes),
createdAt: file.createdAt,
};
}
export async function POST(req: Request) {
const session = await getSessionUser();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await req.formData();
const files = formData.getAll("files") as File[];
if (!files.length) {
return NextResponse.json(
{ error: "No files received in 'files' field." },
{ status: 400 }
);
}
// Check total upload size
const totalSize = files.reduce((acc, file) => acc + (file instanceof File ? file.size : 0), 0);
if (totalSize > MAX_UPLOAD_SIZE) {
return NextResponse.json(
{ error: "Total upload size exceeds 10GB limit." },
{ status: 413 }
);
}
const uploaded = [];
for (const file of files) {
if (!(file instanceof File)) continue;
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const fileWithPath = file as File & { webkitRelativePath?: string };
const relativePath =
fileWithPath.webkitRelativePath ?? fileWithPath.name ?? "unnamed";
const normalizedPath = relativePath.replace(/\\/g, "/");
const name = normalizedPath.split("/").pop() ?? normalizedPath;
const key = `${session.userId}/${Date.now()}-${normalizedPath}`;
const contentType = file.type || "application/octet-stream";
await uploadToR2({
key,
contentType,
body: buffer,
});
const record = await prisma.file.create({
data: {
key,
name,
relativePath: normalizedPath,
contentType,
sizeBytes: BigInt(file.size),
userId: session.userId,
},
});
uploaded.push(serializeFile(record));
}
return NextResponse.json({ ok: true, files: uploaded });
}

721
app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,721 @@
"use client";
import { FormEvent, useEffect, useRef, useState, DragEvent } from "react";
import { useRouter } from "next/navigation";
type FileRow = {
id: string;
name: string;
relativePath: string;
contentType: string;
sizeBytes: number;
createdAt: string | Date;
};
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 * 1024; // 10GB in bytes
function formatBytes(bytes: number) {
if (!bytes) return "0 B";
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), sizes.length - 1);
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(value >= 10 || value % 1 === 0 ? 0 : 1)} ${sizes[i]}`;
}
function getFileIcon(contentType: string) {
if (contentType.startsWith("image/")) {
return (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
);
}
if (contentType.startsWith("video/")) {
return (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
);
}
if (contentType.includes("pdf")) {
return (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
);
}
return (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
);
}
export default function DashboardPage() {
const router = useRouter();
const [files, setFiles] = useState<FileRow[]>([]);
const [loadingList, setLoadingList] = useState(true);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [deletingAll, setDeletingAll] = useState(false);
const [showDeleteAllConfirm, setShowDeleteAllConfirm] = useState(false);
const [deletingFileId, setDeletingFileId] = useState<string | null>(null);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const folderInputRef = useRef<HTMLInputElement | null>(null);
const loadFiles = async () => {
setLoadingList(true);
const res = await fetch("/api/files/list");
const data = await res.json();
setLoadingList(false);
if (!res.ok) {
setError(data?.error ?? "Unable to load files");
return;
}
setFiles(data.files ?? []);
};
useEffect(() => {
loadFiles();
}, []);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const fileArray = Array.from(files);
setSelectedFiles((prev) => [...prev, ...fileArray]);
}
// Reset input to allow selecting the same file again
e.target.value = "";
};
const getTotalSelectedSize = () => {
return selectedFiles.reduce((acc, f) => acc + f.size, 0);
};
const isOverSizeLimit = () => {
return getTotalSelectedSize() > MAX_UPLOAD_SIZE;
};
const handleUpload = async (e: FormEvent) => {
e.preventDefault();
if (selectedFiles.length === 0) {
setError("Please choose at least one file or folder.");
return;
}
if (isOverSizeLimit()) {
setError("Total upload size exceeds 10GB limit. Please remove some files.");
return;
}
const formData = new FormData();
selectedFiles.forEach((file) => {
const fileWithPath = file as File & { webkitRelativePath?: string };
const filename = fileWithPath.webkitRelativePath || fileWithPath.name;
formData.append("files", fileWithPath, filename);
});
setUploading(true);
setError(null);
const res = await fetch("/api/files/upload", {
method: "POST",
body: formData,
});
const data = await res.json();
setUploading(false);
if (!res.ok) {
setError(data?.error ?? "Upload failed");
return;
}
setFiles((prev) => [...data.files, ...prev]);
setSelectedFiles([]);
};
const clearSelectedFiles = () => {
setSelectedFiles([]);
};
const addFilesToSelection = (fileList: FileList) => {
setSelectedFiles((prev) => [...prev, ...Array.from(fileList)]);
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
addFilesToSelection(e.dataTransfer.files);
}
};
const handleDownload = async (id: string) => {
const res = await fetch(`/api/files/${id}/download`);
const data = await res.json();
if (!res.ok) {
setError(data?.error ?? "Unable to download");
return;
}
window.location.assign(data.url);
};
const handleDelete = async (id: string) => {
setDeletingFileId(id);
setError(null);
const res = await fetch(`/api/files/${id}/delete`, { method: "DELETE" });
const data = await res.json();
setDeletingFileId(null);
if (!res.ok) {
setError(data?.error ?? "Unable to delete");
return;
}
setFiles((prev) => prev.filter((f) => f.id !== id));
};
const handleDeleteAll = async () => {
setDeletingAll(true);
setError(null);
const res = await fetch("/api/files/delete-all", { method: "DELETE" });
const data = await res.json();
setDeletingAll(false);
setShowDeleteAllConfirm(false);
if (!res.ok) {
setError(data?.error ?? "Unable to delete all files");
return;
}
setFiles([]);
};
const handleLogout = async () => {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
};
return (
<div className="min-h-screen flex flex-col">
{/* Header */}
<header className="border-b-4 border-accent-lime bg-bg-card sticky top-0 z-50">
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
{/* Logo */}
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-accent-lime flex items-center justify-center">
<svg className="w-6 h-6 text-bg-dark" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<div className="hidden sm:block">
<h1 className="text-lg font-black text-text-primary tracking-tight">
Asa's FTP
</h1>
<p className="text-xs text-text-muted uppercase tracking-wider">
Asa's FTP Storage
</p>
</div>
</div>
</div>
<button
onClick={handleLogout}
className="brutal-btn-outline text-xs px-4 py-2"
style={{ boxShadow: "3px 3px 0 var(--accent-lime)" }}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span className="hidden sm:inline">Logout</span>
</button>
</div>
</div>
</header>
{/* Main Content */}
<main className="flex-1 mx-auto w-full max-w-6xl px-4 sm:px-6 py-6 sm:py-8">
{/* Upload Section */}
<section className="mb-8 animate-slide-up">
<div className="flex items-center gap-3 mb-4">
<div className="w-2 h-8 bg-accent-cyan" />
<h2 className="text-xl font-black text-text-primary uppercase tracking-tight">
Upload Files
</h2>
</div>
<div
className={`brutal-card p-6 sm:p-8 transition-all duration-200 ${
isDragging ? "border-accent-cyan" : ""
}`}
style={{
boxShadow: isDragging
? "8px 8px 0 var(--accent-cyan)"
: "8px 8px 0 var(--accent-lime)"
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<form onSubmit={handleUpload}>
{/* Drop Zone */}
<div
className={`border-3 border-dashed rounded-none p-8 sm:p-12 text-center transition-colors ${
isDragging
? "border-accent-cyan bg-accent-cyan/5"
: "border-text-muted hover:border-accent-lime"
}`}
style={{ borderWidth: "3px" }}
>
<div className="flex flex-col items-center gap-4">
<div className={`w-16 h-16 flex items-center justify-center transition-colors ${
isDragging ? "bg-accent-cyan" : "bg-bg-elevated"
}`}>
<svg
className={`w-8 h-8 transition-colors ${
isDragging ? "text-bg-dark" : "text-text-secondary"
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<div>
<p className="text-text-primary font-bold text-lg">
{isDragging ? "Drop files here" : "Drag & drop files here"}
</p>
<p className="text-text-muted text-sm mt-1">
or select files/folders from your computer
</p>
</div>
{/* Hidden file inputs */}
<input
type="file"
multiple
ref={fileInputRef}
onChange={handleFileSelect}
style={{ display: "none" }}
/>
<input
type="file"
multiple
ref={folderInputRef}
onChange={handleFileSelect}
// @ts-expect-error webkitdirectory is valid in browsers
webkitdirectory=""
style={{ display: "none" }}
/>
<div className="flex flex-wrap items-center justify-center gap-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="brutal-btn-outline cursor-pointer text-sm"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Select Files
</button>
<button
type="button"
onClick={() => folderInputRef.current?.click()}
className="brutal-btn-outline cursor-pointer text-sm"
style={{
borderColor: "var(--accent-cyan)",
boxShadow: "4px 4px 0 var(--accent-cyan)"
}}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
Select Folder
</button>
</div>
</div>
</div>
{/* Selected Files Preview */}
{selectedFiles.length > 0 && (
<div className={`mt-6 p-4 bg-bg-elevated border-2 ${isOverSizeLimit() ? "border-danger" : "border-text-muted"}`}>
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-bold text-text-primary">
{selectedFiles.length} {selectedFiles.length === 1 ? "file" : "files"} selected
<span className={`ml-2 font-normal ${isOverSizeLimit() ? "text-danger" : "text-text-muted"}`}>
({formatBytes(getTotalSelectedSize())} / 10 GB max)
</span>
</p>
<button
type="button"
onClick={clearSelectedFiles}
className="text-xs text-text-muted hover:text-danger transition-colors"
>
Clear all
</button>
</div>
{isOverSizeLimit() && (
<div className="flex items-center gap-2 mb-3 p-2 bg-danger/10 border border-danger text-danger text-xs">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Total size exceeds 10GB limit. Remove some files to continue.
</div>
)}
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{selectedFiles.slice(0, 10).map((file, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1.5 px-2 py-1 bg-bg-card text-xs text-text-secondary border border-text-muted"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{file.name.length > 25 ? file.name.slice(0, 22) + "..." : file.name}
</span>
))}
{selectedFiles.length > 10 && (
<span className="inline-flex items-center px-2 py-1 bg-accent-lime/20 text-xs text-accent-lime font-bold">
+{selectedFiles.length - 10} more
</span>
)}
</div>
</div>
)}
{/* Upload Button */}
<div className="mt-6 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4">
<p className="text-text-muted text-sm">
{selectedFiles.length === 0
? "Supports files and entire folders with preserved structure"
: "Ready to upload"}
</p>
<button
type="submit"
className="brutal-btn"
disabled={uploading || selectedFiles.length === 0 || isOverSizeLimit()}
>
{uploading ? (
<>
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Uploading...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Upload {selectedFiles.length > 0 ? `${selectedFiles.length} Files` : "Files"}
</>
)}
</button>
</div>
</form>
</div>
{/* Error Message */}
{error && (
<div className="mt-4 flex items-center gap-3 p-4 bg-danger/10 border-l-4 border-danger animate-slide-left">
<svg className="w-5 h-5 text-danger flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-danger font-medium">{error}</p>
<button
onClick={() => setError(null)}
className="ml-auto text-danger hover:text-text-primary"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
</section>
{/* Files Section */}
<section className="animate-slide-up delay-200" style={{ opacity: 0, animationFillMode: "forwards" }}>
<div className="flex items-center justify-between gap-4 mb-4">
<div className="flex items-center gap-3">
<div className="w-2 h-8 bg-accent-magenta" />
<h2 className="text-xl font-black text-text-primary uppercase tracking-tight">
Your Files
</h2>
<span className="text-xs font-bold text-text-muted bg-bg-elevated px-2 py-1">
{files.length} {files.length === 1 ? "FILE" : "FILES"}
</span>
</div>
<div className="flex items-center gap-2">
{files.length > 0 && (
<button
className="brutal-btn-danger text-xs px-3 py-2"
onClick={() => setShowDeleteAllConfirm(true)}
disabled={deletingAll}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete All
</button>
)}
<button
className="brutal-btn-outline text-xs px-3 py-2"
onClick={loadFiles}
disabled={loadingList}
style={{
borderColor: "var(--accent-magenta)",
boxShadow: "3px 3px 0 var(--accent-magenta)"
}}
>
<svg
className={`w-4 h-4 ${loadingList ? "animate-spin" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{loadingList ? "Loading..." : "Refresh"}
</button>
</div>
</div>
<div className="brutal-card overflow-hidden" style={{ boxShadow: "8px 8px 0 var(--accent-magenta)" }}>
{loadingList ? (
<div className="p-12 text-center">
<div className="inline-flex items-center gap-3">
<svg className="animate-spin h-6 w-6 text-accent-magenta" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span className="text-text-secondary font-medium">Loading your files...</span>
</div>
</div>
) : files.length === 0 ? (
<div className="p-12 text-center">
<div className="w-20 h-20 mx-auto mb-6 bg-bg-elevated flex items-center justify-center">
<svg className="w-10 h-10 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-xl font-black text-text-primary mb-2">No files yet</h3>
<p className="text-text-muted text-sm max-w-sm mx-auto">
Upload your first file or folder using the upload section above.
</p>
</div>
) : (
<>
{/* Desktop Table */}
<div className="hidden md:block overflow-x-auto">
<table className="brutal-table">
<thead>
<tr>
<th className="w-12"></th>
<th>Name</th>
<th>Path</th>
<th>Size</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{files.map((file, index) => (
<tr
key={file.id}
className="animate-slide-up"
style={{
opacity: 0,
animationFillMode: "forwards",
animationDelay: `${index * 50}ms`
}}
>
<td className="text-text-muted">
{getFileIcon(file.contentType)}
</td>
<td className="font-bold text-text-primary">{file.name}</td>
<td className="text-text-muted font-mono text-xs">{file.relativePath}</td>
<td className="text-text-secondary">{formatBytes(file.sizeBytes)}</td>
<td>
<div className="flex items-center justify-end gap-2">
<button
className="brutal-btn-outline text-xs px-3 py-1.5"
onClick={() => handleDownload(file.id)}
style={{ boxShadow: "2px 2px 0 var(--accent-lime)" }}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download
</button>
<button
className="brutal-btn-danger"
onClick={() => handleDelete(file.id)}
disabled={deletingFileId === file.id}
>
{deletingFileId === file.id ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Deleting...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</>
)}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Cards */}
<div className="md:hidden divide-y divide-bg-elevated">
{files.map((file, index) => (
<div
key={file.id}
className="p-4 animate-slide-up"
style={{
opacity: 0,
animationFillMode: "forwards",
animationDelay: `${index * 50}ms`
}}
>
<div className="flex items-start gap-3">
<div className="text-text-muted mt-0.5">
{getFileIcon(file.contentType)}
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-text-primary truncate">{file.name}</p>
<p className="text-xs text-text-muted font-mono truncate mt-1">{file.relativePath}</p>
<p className="text-xs text-text-secondary mt-1">{formatBytes(file.sizeBytes)}</p>
</div>
</div>
<div className="flex items-center gap-2 mt-4">
<button
className="flex-1 brutal-btn-outline text-xs py-2"
onClick={() => handleDownload(file.id)}
style={{ boxShadow: "2px 2px 0 var(--accent-lime)" }}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download
</button>
<button
className="brutal-btn-danger py-2 px-4"
onClick={() => handleDelete(file.id)}
disabled={deletingFileId === file.id}
>
{deletingFileId === file.id ? (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
)}
</button>
</div>
</div>
))}
</div>
</>
)}
</div>
</section>
</main>
{/* Delete All Confirmation Modal */}
{showDeleteAllConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-bg-dark/80 animate-fade-in">
<div
className="brutal-card p-6 max-w-md w-full animate-slide-up"
style={{ boxShadow: "8px 8px 0 var(--danger)" }}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-danger/20 flex items-center justify-center">
<svg className="w-6 h-6 text-danger" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h3 className="text-lg font-black text-text-primary">Delete All Files?</h3>
<p className="text-sm text-text-muted">This action cannot be undone</p>
</div>
</div>
<p className="text-text-secondary mb-6">
You are about to permanently delete <span className="font-bold text-danger">{files.length} {files.length === 1 ? "file" : "files"}</span> from your storage. This action is irreversible.
</p>
<div className="flex items-center gap-3">
<button
className="flex-1 brutal-btn-outline text-sm py-2.5"
onClick={() => setShowDeleteAllConfirm(false)}
disabled={deletingAll}
>
Cancel
</button>
<button
className="flex-1 brutal-btn-danger text-sm py-2.5"
onClick={handleDeleteAll}
disabled={deletingAll}
>
{deletingAll ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Deleting...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete All
</>
)}
</button>
</div>
</div>
</div>
)}
{/* Footer */}
<footer className="border-t-2 border-bg-elevated py-4 mt-auto">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="flex flex-col sm:flex-row items-center justify-evenly gap-2 text-xs text-text-muted">
<p>FTP</p>
<p>asabizanjo.dev</p>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-accent-lime" />
<div className="w-2 h-2 bg-accent-magenta" />
<div className="w-2 h-2 bg-accent-cyan" />
</div>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -1,26 +1,463 @@
@import "tailwindcss";
/* Satoshi Font Family */
@font-face {
font-family: 'Satoshi';
src: url('/Fonts/WEB/fonts/Satoshi-Variable.woff2') format('woff2'),
url('/Fonts/WEB/fonts/Satoshi-Variable.woff') format('woff'),
url('/Fonts/WEB/fonts/Satoshi-Variable.ttf') format('truetype');
font-weight: 300 900;
font-display: swap;
font-style: normal;
}
@font-face {
font-family: 'Satoshi';
src: url('/Fonts/WEB/fonts/Satoshi-VariableItalic.woff2') format('woff2'),
url('/Fonts/WEB/fonts/Satoshi-VariableItalic.woff') format('woff'),
url('/Fonts/WEB/fonts/Satoshi-VariableItalic.ttf') format('truetype');
font-weight: 300 900;
font-display: swap;
font-style: italic;
}
/* Neo-Brutalism Dark Theme Variables */
:root {
--background: #ffffff;
--foreground: #171717;
/* Background Colors */
--bg-dark: #0d0d0d;
--bg-card: #1a1a1a;
--bg-elevated: #252525;
/* Accent Colors */
--accent-lime: #BFFF00;
--accent-magenta: #FF00FF;
--accent-cyan: #00FFFF;
--accent-orange: #FF6B00;
/* Text Colors */
--text-primary: #FAFAFA;
--text-secondary: #A0A0A0;
--text-muted: #666666;
/* Danger/Error */
--danger: #FF3B3B;
--danger-soft: #FF3B3B33;
/* Border & Shadow */
--border-width: 3px;
--shadow-offset: 4px;
--shadow-offset-lg: 8px;
/* Font */
--font-satoshi: 'Satoshi', system-ui, -apple-system, sans-serif;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-bg-dark: var(--bg-dark);
--color-bg-card: var(--bg-card);
--color-bg-elevated: var(--bg-elevated);
--color-accent-lime: var(--accent-lime);
--color-accent-magenta: var(--accent-magenta);
--color-accent-cyan: var(--accent-cyan);
--color-accent-orange: var(--accent-orange);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-muted: var(--text-muted);
--color-danger: var(--danger);
--font-sans: var(--font-satoshi);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
/* Base Styles */
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
font-family: var(--font-satoshi);
font-weight: 500;
line-height: 1.6;
min-height: 100vh;
}
/* Grid Background Pattern */
.bg-grid {
background-image:
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
}
/* Noise Texture Overlay */
.bg-noise::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.015;
z-index: 100;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}
/* ================================
BRUTAL COMPONENT UTILITIES
================================ */
/* Brutal Card */
.brutal-card {
background: var(--bg-card);
border: var(--border-width) solid var(--text-primary);
box-shadow: var(--shadow-offset-lg) var(--shadow-offset-lg) 0 var(--accent-lime);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.brutal-card:hover {
transform: translate(-2px, -2px);
box-shadow: calc(var(--shadow-offset-lg) + 2px) calc(var(--shadow-offset-lg) + 2px) 0 var(--accent-lime);
}
/* Brutal Card - Magenta variant */
.brutal-card-magenta {
background: var(--bg-card);
border: var(--border-width) solid var(--text-primary);
box-shadow: var(--shadow-offset-lg) var(--shadow-offset-lg) 0 var(--accent-magenta);
}
/* Brutal Button - Primary */
.brutal-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
font-family: var(--font-satoshi);
font-weight: 700;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bg-dark);
background: var(--accent-lime);
border: var(--border-width) solid var(--bg-dark);
box-shadow: var(--shadow-offset) var(--shadow-offset) 0 var(--bg-dark);
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.brutal-btn:hover {
transform: translate(-2px, -2px);
box-shadow: calc(var(--shadow-offset) + 2px) calc(var(--shadow-offset) + 2px) 0 var(--bg-dark);
}
.brutal-btn:active {
transform: translate(2px, 2px);
box-shadow: 0 0 0 var(--bg-dark);
}
.brutal-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Brutal Button - Outline */
.brutal-btn-outline {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
font-family: var(--font-satoshi);
font-weight: 700;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-primary);
background: transparent;
border: var(--border-width) solid var(--accent-lime);
box-shadow: var(--shadow-offset) var(--shadow-offset) 0 var(--accent-lime);
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.1s ease;
}
.brutal-btn-outline:hover {
background: var(--accent-lime);
color: var(--bg-dark);
transform: translate(-2px, -2px);
box-shadow: calc(var(--shadow-offset) + 2px) calc(var(--shadow-offset) + 2px) 0 var(--accent-lime);
}
.brutal-btn-outline:active {
transform: translate(2px, 2px);
box-shadow: 0 0 0 var(--accent-lime);
}
/* Brutal Button - Danger */
.brutal-btn-danger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-family: var(--font-satoshi);
font-weight: 700;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--danger);
background: transparent;
border: 2px solid var(--danger);
box-shadow: 3px 3px 0 var(--danger);
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.1s ease;
}
.brutal-btn-danger:hover {
background: var(--danger);
color: var(--text-primary);
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0 var(--danger);
}
/* Brutal Button - Small */
.brutal-btn-sm {
padding: 0.5rem 1rem;
font-size: 0.75rem;
box-shadow: 3px 3px 0 var(--bg-dark);
}
/* Brutal Input */
.brutal-input {
width: 100%;
padding: 0.875rem 1rem;
font-family: var(--font-satoshi);
font-size: 1rem;
font-weight: 500;
color: var(--text-primary);
background: var(--bg-elevated);
border: none;
border-bottom: var(--border-width) solid var(--accent-lime);
outline: none;
transition: border-color 0.15s ease, background 0.15s ease;
}
.brutal-input::placeholder {
color: var(--text-muted);
}
.brutal-input:focus {
border-bottom-color: var(--accent-cyan);
background: var(--bg-card);
}
/* Brutal Label */
.brutal-label {
display: block;
margin-bottom: 0.5rem;
font-family: var(--font-satoshi);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-secondary);
}
/* Brutal Link */
.brutal-link {
color: var(--accent-lime);
font-weight: 700;
text-decoration: none;
border-bottom: 2px solid transparent;
transition: border-color 0.15s ease;
}
.brutal-link:hover {
border-bottom-color: var(--accent-lime);
}
/* Brutal Divider */
.brutal-divider {
height: 3px;
background: linear-gradient(90deg, var(--accent-lime), var(--accent-magenta), var(--accent-cyan));
}
/* ================================
GEOMETRIC DECORATIONS
================================ */
.geo-accent {
position: absolute;
pointer-events: none;
}
.geo-square {
width: 80px;
height: 80px;
border: 4px solid var(--accent-lime);
transform: rotate(45deg);
}
.geo-circle {
width: 120px;
height: 120px;
border: 4px solid var(--accent-magenta);
border-radius: 50%;
}
.geo-line {
width: 200px;
height: 4px;
background: var(--accent-cyan);
}
/* ================================
ANIMATIONS
================================ */
@keyframes float {
0%, 100% {
transform: translateY(0) rotate(45deg);
}
50% {
transform: translateY(-20px) rotate(45deg);
}
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 20px var(--accent-lime);
}
50% {
box-shadow: 0 0 40px var(--accent-lime);
}
}
@keyframes slide-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-in-left {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-slide-up {
animation: slide-in-up 0.4s ease-out forwards;
}
.animate-slide-left {
animation: slide-in-left 0.4s ease-out forwards;
}
/* Staggered animation delays */
.delay-100 { animation-delay: 100ms; }
.delay-200 { animation-delay: 200ms; }
.delay-300 { animation-delay: 300ms; }
.delay-400 { animation-delay: 400ms; }
.delay-500 { animation-delay: 500ms; }
/* ================================
TABLE STYLES
================================ */
.brutal-table {
width: 100%;
border-collapse: collapse;
}
.brutal-table th {
padding: 1rem;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
text-align: left;
border-bottom: 2px solid var(--text-muted);
}
.brutal-table td {
padding: 1rem;
font-size: 0.875rem;
border-bottom: 1px solid var(--bg-elevated);
}
.brutal-table tbody tr {
transition: background 0.15s ease;
}
.brutal-table tbody tr:hover {
background: var(--bg-elevated);
}
/* ================================
SCROLLBAR STYLING
================================ */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--bg-dark);
}
::-webkit-scrollbar-thumb {
background: var(--bg-elevated);
border: 2px solid var(--bg-dark);
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* ================================
RESPONSIVE UTILITIES
================================ */
@media (max-width: 640px) {
.brutal-card {
box-shadow: var(--shadow-offset) var(--shadow-offset) 0 var(--accent-lime);
}
.brutal-btn {
width: 100%;
padding: 1rem;
}
.geo-square,
.geo-circle {
display: none;
}
}
/* Selection styling */
::selection {
background: var(--accent-lime);
color: var(--bg-dark);
}

View File

@@ -1,20 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Asa's FTP",
description: "Upload and download files via Asa's FTP",
};
export default function RootLayout({
@@ -24,9 +13,7 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<body className="bg-grid bg-noise">
{children}
</body>
</html>

View File

@@ -1,65 +1,10 @@
import Image from "next/image";
import { redirect } from "next/navigation";
import { getSessionUser } from "@/lib/auth";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
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={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] 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"
>
Documentation
</a>
</div>
</main>
</div>
);
export default async function Home() {
const session = await getSessionUser();
if (session) {
redirect("/dashboard");
}
redirect("/login");
}

80
lib/auth.ts Normal file
View File

@@ -0,0 +1,80 @@
import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose";
import bcrypt from "bcryptjs";
import { prisma } from "./db";
const SESSION_COOKIE_NAME = "ftp_session";
const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
type SessionPayload = {
userId: string;
email: string;
};
function getJwtSecret() {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET is not set");
}
return new TextEncoder().encode(secret);
}
export async function hashPassword(password: string) {
return bcrypt.hash(password, 10);
}
export async function verifyPassword(password: string, hash: string) {
return bcrypt.compare(password, hash);
}
export async function createSessionToken(payload: SessionPayload) {
const secret = getJwtSecret();
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${SESSION_MAX_AGE}s`)
.sign(secret);
}
export async function verifySessionToken(token: string) {
try {
const { payload } = await jwtVerify<SessionPayload>(token, getJwtSecret());
return payload;
} catch {
return null;
}
}
export async function setSessionCookie(token: string) {
const store = await cookies();
store.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: SESSION_MAX_AGE,
});
}
export async function clearSessionCookie() {
const store = await cookies();
store.delete(SESSION_COOKIE_NAME);
}
export async function getSessionUser() {
const store = await cookies();
const token = store.get(SESSION_COOKIE_NAME)?.value;
if (!token) return null;
const payload = await verifySessionToken(token);
if (!payload) return null;
return payload;
}
export async function getCurrentUserRecord() {
const session = await getSessionUser();
if (!session) return null;
return prisma.user.findUnique({ where: { id: session.userId } });
}
export { SESSION_COOKIE_NAME, SESSION_MAX_AGE };

16
lib/db.ts Normal file
View File

@@ -0,0 +1,16 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

74
lib/r2.ts Normal file
View File

@@ -0,0 +1,74 @@
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
let cachedClient: S3Client | null = null;
let cachedBucket: string | null = null;
function getClient() {
if (cachedClient && cachedBucket) {
return { s3: cachedClient, bucket: cachedBucket };
}
const {
R2_ENDPOINT,
R2_REGION = "auto",
R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY,
R2_BUCKET,
} = process.env;
if (!R2_ENDPOINT || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_BUCKET) {
throw new Error("R2 configuration is missing. Check env variables.");
}
cachedBucket = R2_BUCKET;
cachedClient = new S3Client({
endpoint: R2_ENDPOINT,
region: R2_REGION,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
},
});
return { s3: cachedClient, bucket: cachedBucket };
}
export async function uploadToR2(params: {
key: string;
contentType: string;
body: Buffer;
}) {
const { s3, bucket } = getClient();
const command = new PutObjectCommand({
Bucket: bucket,
Key: params.key,
Body: params.body,
ContentType: params.contentType,
});
await s3.send(command);
}
export async function getSignedDownloadUrl(key: string, expiresInSeconds = 300) {
const { s3, bucket } = getClient();
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
return getSignedUrl(s3, command, { expiresIn: expiresInSeconds });
}
export async function deleteFromR2(key: string) {
const { s3, bucket } = getClient();
const command = new DeleteObjectCommand({
Bucket: bucket,
Key: key,
});
await s3.send(command);
}

20
middleware.ts Normal file
View File

@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { SESSION_COOKIE_NAME, verifySessionToken } from "./lib/auth";
export async function middleware(req: NextRequest) {
const token = req.cookies.get(SESSION_COOKIE_NAME)?.value;
const session = token ? await verifySessionToken(token) : null;
if (!session) {
const loginUrl = new URL("/login", req.url);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/api/files/:path*"],
};

View File

@@ -1,7 +1,18 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// Disable server/dev sourcemaps to avoid Windows source map parse warnings
productionBrowserSourceMaps: false,
webpack: (config, { dev, isServer }) => {
if (dev && isServer) {
config.devtool = false;
}
return config;
},
// Acknowledge Turbopack config to silence migration warning
turbopack: {},
};
export default nextConfig;

1807
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,13 +3,19 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -p 4000 --webpack",
"build": "next build",
"start": "next start",
"start": "next start -p 4000",
"lint": "eslint"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.948.0",
"@aws-sdk/s3-request-presigner": "^3.948.0",
"@prisma/client": "^5.19.1",
"bcryptjs": "^3.0.3",
"jose": "^6.1.3",
"next": "16.0.8",
"prisma": "^5.19.1",
"react": "19.2.1",
"react-dom": "19.2.1"
},

BIN
prisma/dev.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "File" (
"id" TEXT NOT NULL PRIMARY KEY,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"contentType" TEXT NOT NULL,
"sizeBytes" BIGINT NOT NULL,
"relativePath" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "File_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "File_key_key" ON "File"("key");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

31
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,31 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
files File[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model File {
id String @id @default(cuid())
key String @unique
name String
contentType String
sizeBytes BigInt
relativePath String
user User @relation(fields: [userId], references: [id])
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,43 @@
# Installing Webfonts
Follow these simple Steps.
## 1.
Put `satoshi/` Folder into a Folder called `fonts/`.
## 2.
Put `satoshi.css` into your `css/` Folder.
## 3. (Optional)
You may adapt the `url('path')` in `satoshi.css` depends on your Website Filesystem.
## 4.
Import `satoshi.css` at the top of you main Stylesheet.
```
@import url('satoshi.css');
```
## 5.
You are now ready to use the following Rules in your CSS to specify each Font Style:
```
font-family: Satoshi-Light;
font-family: Satoshi-LightItalic;
font-family: Satoshi-Regular;
font-family: Satoshi-Italic;
font-family: Satoshi-Medium;
font-family: Satoshi-MediumItalic;
font-family: Satoshi-Bold;
font-family: Satoshi-BoldItalic;
font-family: Satoshi-Black;
font-family: Satoshi-BlackItalic;
font-family: Satoshi-Variable;
font-family: Satoshi-VariableItalic;
```
## 6. (Optional)
Use `font-variation-settings` rule to controll axes of variable fonts:
wght 900.0
Available axes:
'wght' (range from 300.0 to 900.0

View File

@@ -0,0 +1,148 @@
/**
* @license
*
* Font Family: Satoshi
* Designed by: Deni Anggara
* URL: https://www.fontshare.com/fonts/satoshi
* © 2025 Indian Type Foundry
*
* Satoshi Light
* Satoshi LightItalic
* Satoshi Regular
* Satoshi Italic
* Satoshi Medium
* Satoshi MediumItalic
* Satoshi Bold
* Satoshi BoldItalic
* Satoshi Black
* Satoshi BlackItalic
* Satoshi Variable (Variable font)
* Satoshi VariableItalic (Variable font)
*
*/
@font-face {
font-family: 'Satoshi-Light';
src: url('../fonts/Satoshi-Light.woff2') format('woff2'),
url('../fonts/Satoshi-Light.woff') format('woff'),
url('../fonts/Satoshi-Light.ttf') format('truetype');
font-weight: 300;
font-display: swap;
font-style: normal;
}
@font-face {
font-family: 'Satoshi-LightItalic';
src: url('../fonts/Satoshi-LightItalic.woff2') format('woff2'),
url('../fonts/Satoshi-LightItalic.woff') format('woff'),
url('../fonts/Satoshi-LightItalic.ttf') format('truetype');
font-weight: 300;
font-display: swap;
font-style: italic;
}
@font-face {
font-family: 'Satoshi-Regular';
src: url('../fonts/Satoshi-Regular.woff2') format('woff2'),
url('../fonts/Satoshi-Regular.woff') format('woff'),
url('../fonts/Satoshi-Regular.ttf') format('truetype');
font-weight: 400;
font-display: swap;
font-style: normal;
}
@font-face {
font-family: 'Satoshi-Italic';
src: url('../fonts/Satoshi-Italic.woff2') format('woff2'),
url('../fonts/Satoshi-Italic.woff') format('woff'),
url('../fonts/Satoshi-Italic.ttf') format('truetype');
font-weight: 400;
font-display: swap;
font-style: italic;
}
@font-face {
font-family: 'Satoshi-Medium';
src: url('../fonts/Satoshi-Medium.woff2') format('woff2'),
url('../fonts/Satoshi-Medium.woff') format('woff'),
url('../fonts/Satoshi-Medium.ttf') format('truetype');
font-weight: 500;
font-display: swap;
font-style: normal;
}
@font-face {
font-family: 'Satoshi-MediumItalic';
src: url('../fonts/Satoshi-MediumItalic.woff2') format('woff2'),
url('../fonts/Satoshi-MediumItalic.woff') format('woff'),
url('../fonts/Satoshi-MediumItalic.ttf') format('truetype');
font-weight: 500;
font-display: swap;
font-style: italic;
}
@font-face {
font-family: 'Satoshi-Bold';
src: url('../fonts/Satoshi-Bold.woff2') format('woff2'),
url('../fonts/Satoshi-Bold.woff') format('woff'),
url('../fonts/Satoshi-Bold.ttf') format('truetype');
font-weight: 700;
font-display: swap;
font-style: normal;
}
@font-face {
font-family: 'Satoshi-BoldItalic';
src: url('../fonts/Satoshi-BoldItalic.woff2') format('woff2'),
url('../fonts/Satoshi-BoldItalic.woff') format('woff'),
url('../fonts/Satoshi-BoldItalic.ttf') format('truetype');
font-weight: 700;
font-display: swap;
font-style: italic;
}
@font-face {
font-family: 'Satoshi-Black';
src: url('../fonts/Satoshi-Black.woff2') format('woff2'),
url('../fonts/Satoshi-Black.woff') format('woff'),
url('../fonts/Satoshi-Black.ttf') format('truetype');
font-weight: 900;
font-display: swap;
font-style: normal;
}
@font-face {
font-family: 'Satoshi-BlackItalic';
src: url('../fonts/Satoshi-BlackItalic.woff2') format('woff2'),
url('../fonts/Satoshi-BlackItalic.woff') format('woff'),
url('../fonts/Satoshi-BlackItalic.ttf') format('truetype');
font-weight: 900;
font-display: swap;
font-style: italic;
}
/**
* This is a variable font
* You can control variable axes as shown below:
* font-variation-settings: wght 900.0;
*
* available axes:
'wght' (range from 300.0 to 900.0
*/
@font-face {
font-family: 'Satoshi-Variable';
src: url('../fonts/Satoshi-Variable.woff2') format('woff2'),
url('../fonts/Satoshi-Variable.woff') format('woff'),
url('../fonts/Satoshi-Variable.ttf') format('truetype');
font-weight: 300 900;
font-display: swap;
font-style: normal;
}
/**
* This is a variable font
* You can control variable axes as shown below:
* font-variation-settings: wght 900.0;
*
* available axes:
'wght' (range from 300.0 to 900.0
*/
@font-face {
font-family: 'Satoshi-VariableItalic';
src: url('../fonts/Satoshi-VariableItalic.woff2') format('woff2'),
url('../fonts/Satoshi-VariableItalic.woff') format('woff'),
url('../fonts/Satoshi-VariableItalic.ttf') format('truetype');
font-weight: 300 900;
font-display: swap;
font-style: italic;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.