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

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