gucci
This commit is contained in:
64
README.md
64
README.md
@@ -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
141
app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
app/(auth)/register/page.tsx
Normal file
158
app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
app/api/auth/login/route.ts
Normal file
39
app/api/auth/login/route.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
|
||||
8
app/api/auth/logout/route.ts
Normal file
8
app/api/auth/logout/route.ts
Normal 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 });
|
||||
}
|
||||
|
||||
42
app/api/auth/register/route.ts
Normal file
42
app/api/auth/register/route.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
|
||||
31
app/api/files/[id]/delete/route.ts
Normal file
31
app/api/files/[id]/delete/route.ts
Normal 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 });
|
||||
}
|
||||
|
||||
29
app/api/files/[id]/download/route.ts
Normal file
29
app/api/files/[id]/download/route.ts
Normal 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 });
|
||||
}
|
||||
|
||||
33
app/api/files/delete-all/route.ts
Normal file
33
app/api/files/delete-all/route.ts
Normal 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 });
|
||||
}
|
||||
|
||||
28
app/api/files/list/route.ts
Normal file
28
app/api/files/list/route.ts
Normal 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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
81
app/api/files/upload/route.ts
Normal file
81
app/api/files/upload/route.ts
Normal 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
721
app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
465
app/globals.css
465
app/globals.css
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
71
app/page.tsx
71
app/page.tsx
@@ -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
80
lib/auth.ts
Normal 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
16
lib/db.ts
Normal 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
74
lib/r2.ts
Normal 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
20
middleware.ts
Normal 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*"],
|
||||
};
|
||||
|
||||
@@ -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
1807
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -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
BIN
prisma/dev.db
Normal file
Binary file not shown.
28
prisma/migrations/20251211003826_init/migration.sql
Normal file
28
prisma/migrations/20251211003826_init/migration.sql
Normal 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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
31
prisma/schema.prisma
Normal 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
|
||||
}
|
||||
|
||||
BIN
public/Fonts/OTF/Satoshi-Black.otf
Normal file
BIN
public/Fonts/OTF/Satoshi-Black.otf
Normal file
Binary file not shown.
BIN
public/Fonts/OTF/Satoshi-BlackItalic.otf
Normal file
BIN
public/Fonts/OTF/Satoshi-BlackItalic.otf
Normal file
Binary file not shown.
BIN
public/Fonts/OTF/Satoshi-Bold.otf
Normal file
BIN
public/Fonts/OTF/Satoshi-Bold.otf
Normal file
Binary file not shown.
BIN
public/Fonts/OTF/Satoshi-BoldItalic.otf
Normal file
BIN
public/Fonts/OTF/Satoshi-BoldItalic.otf
Normal file
Binary file not shown.
BIN
public/Fonts/OTF/Satoshi-Italic.otf
Normal file
BIN
public/Fonts/OTF/Satoshi-Italic.otf
Normal file
Binary file not shown.
BIN
public/Fonts/OTF/Satoshi-Light.otf
Normal file
BIN
public/Fonts/OTF/Satoshi-Light.otf
Normal file
Binary file not shown.
BIN
public/Fonts/OTF/Satoshi-LightItalic.otf
Normal file
BIN
public/Fonts/OTF/Satoshi-LightItalic.otf
Normal file
Binary file not shown.
BIN
public/Fonts/OTF/Satoshi-Medium.otf
Normal file
BIN
public/Fonts/OTF/Satoshi-Medium.otf
Normal file
Binary file not shown.
BIN
public/Fonts/OTF/Satoshi-MediumItalic.otf
Normal file
BIN
public/Fonts/OTF/Satoshi-MediumItalic.otf
Normal file
Binary file not shown.
BIN
public/Fonts/OTF/Satoshi-Regular.otf
Normal file
BIN
public/Fonts/OTF/Satoshi-Regular.otf
Normal file
Binary file not shown.
BIN
public/Fonts/TTF/Satoshi-Variable.ttf
Normal file
BIN
public/Fonts/TTF/Satoshi-Variable.ttf
Normal file
Binary file not shown.
BIN
public/Fonts/TTF/Satoshi-VariableItalic.ttf
Normal file
BIN
public/Fonts/TTF/Satoshi-VariableItalic.ttf
Normal file
Binary file not shown.
43
public/Fonts/WEB/README.md
Normal file
43
public/Fonts/WEB/README.md
Normal 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
|
||||
|
||||
148
public/Fonts/WEB/css/satoshi.css
Normal file
148
public/Fonts/WEB/css/satoshi.css
Normal 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;
|
||||
}
|
||||
|
||||
BIN
public/Fonts/WEB/fonts/Satoshi-Black.eot
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Black.eot
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Black.ttf
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Black.ttf
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Black.woff
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Black.woff
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Black.woff2
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Black.woff2
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-BlackItalic.eot
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-BlackItalic.eot
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-BlackItalic.ttf
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-BlackItalic.ttf
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-BlackItalic.woff
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-BlackItalic.woff
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-BlackItalic.woff2
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-BlackItalic.woff2
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Bold.eot
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Bold.eot
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Bold.ttf
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Bold.ttf
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Bold.woff
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Bold.woff
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Bold.woff2
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Bold.woff2
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-BoldItalic.eot
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-BoldItalic.eot
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-BoldItalic.ttf
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-BoldItalic.woff
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-BoldItalic.woff
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-BoldItalic.woff2
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Italic.eot
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Italic.eot
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Italic.ttf
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Italic.ttf
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Italic.woff
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Italic.woff
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Italic.woff2
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Italic.woff2
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Light.eot
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Light.eot
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Light.ttf
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Light.ttf
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Light.woff
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Light.woff
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Light.woff2
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Light.woff2
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-LightItalic.eot
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-LightItalic.eot
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-LightItalic.ttf
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-LightItalic.ttf
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-LightItalic.woff
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-LightItalic.woff
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-LightItalic.woff2
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-LightItalic.woff2
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Medium.eot
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Medium.eot
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Medium.ttf
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Medium.ttf
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Medium.woff
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Medium.woff
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Medium.woff2
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Medium.woff2
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-MediumItalic.eot
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-MediumItalic.eot
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-MediumItalic.ttf
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-MediumItalic.woff
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-MediumItalic.woff
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-MediumItalic.woff2
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-MediumItalic.woff2
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Regular.eot
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Regular.eot
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Regular.ttf
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Regular.ttf
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Regular.woff
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Regular.woff
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Regular.woff2
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Regular.woff2
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Variable.eot
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Variable.eot
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Variable.ttf
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Variable.ttf
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Variable.woff
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Variable.woff
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-Variable.woff2
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-Variable.woff2
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-VariableItalic.eot
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-VariableItalic.eot
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-VariableItalic.ttf
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-VariableItalic.ttf
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-VariableItalic.woff
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-VariableItalic.woff
Normal file
Binary file not shown.
BIN
public/Fonts/WEB/fonts/Satoshi-VariableItalic.woff2
Normal file
BIN
public/Fonts/WEB/fonts/Satoshi-VariableItalic.woff2
Normal file
Binary file not shown.
Reference in New Issue
Block a user