From 0d58fafe4ea5b20a7596bd293ae4c441354fe8d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20B=C3=B6ttcher?= Date: Tue, 12 May 2026 14:58:11 +0200 Subject: [PATCH] feat: implement user authentication and admin management - Add user management API endpoints for listing and creating users. - Implement login and logout functionality with rate limiting. - Create UI components for login form, logout button, and animated elements. - Add session management with JWT and secure cookie handling. - Seed initial admin user if no users exist. - Introduce utility functions for password hashing and user authentication. - Set up proxy middleware for route protection and security headers. - Create a JSON file for user data storage. - Add links configuration for external resources. --- README.md | 94 +- SETUP.md | 162 + frontend/app/admin/UsersClient.tsx | 301 + frontend/app/admin/page.tsx | 42 + frontend/app/api/admin/users/[id]/route.ts | 55 + frontend/app/api/admin/users/route.ts | 41 + frontend/app/api/auth/login/route.ts | 91 + frontend/app/api/auth/logout/route.ts | 7 + frontend/app/api/files/route.ts | 6 + frontend/app/api/upload/route.ts | 6 + frontend/app/browse/BrowseClient.tsx | 336 +- frontend/app/browse/page.tsx | 103 +- frontend/app/components/AnimateIn.tsx | 86 + frontend/app/components/CountUp.tsx | 82 + frontend/app/components/LogoutButton.tsx | 40 + frontend/app/components/SpotlightCard.tsx | 44 + frontend/app/components/TerminalTyper.tsx | 115 + frontend/app/files/[token]/CopyLinkButton.tsx | 49 +- frontend/app/files/[token]/page.tsx | 162 +- frontend/app/globals.css | 283 +- frontend/app/layout.tsx | 186 +- frontend/app/login/LoginForm.tsx | 142 + frontend/app/login/page.tsx | 53 + frontend/app/page.tsx | 663 +- frontend/app/share/page.tsx | 218 +- frontend/bun.lock | 5479 +++-------------- frontend/data/users.json | 10 + frontend/lib/auth.ts | 36 + frontend/lib/links.ts | 31 + frontend/lib/session.ts | 77 + frontend/lib/users.ts | 140 + frontend/next.config.ts | 20 + frontend/package.json | 1 + frontend/proxy.ts | 114 + 34 files changed, 4263 insertions(+), 5012 deletions(-) create mode 100644 SETUP.md create mode 100644 frontend/app/admin/UsersClient.tsx create mode 100644 frontend/app/admin/page.tsx create mode 100644 frontend/app/api/admin/users/[id]/route.ts create mode 100644 frontend/app/api/admin/users/route.ts create mode 100644 frontend/app/api/auth/login/route.ts create mode 100644 frontend/app/api/auth/logout/route.ts create mode 100644 frontend/app/components/AnimateIn.tsx create mode 100644 frontend/app/components/CountUp.tsx create mode 100644 frontend/app/components/LogoutButton.tsx create mode 100644 frontend/app/components/SpotlightCard.tsx create mode 100644 frontend/app/components/TerminalTyper.tsx create mode 100644 frontend/app/login/LoginForm.tsx create mode 100644 frontend/app/login/page.tsx create mode 100644 frontend/data/users.json create mode 100644 frontend/lib/auth.ts create mode 100644 frontend/lib/links.ts create mode 100644 frontend/lib/session.ts create mode 100644 frontend/lib/users.ts create mode 100644 frontend/proxy.ts diff --git a/README.md b/README.md index e215bc4..fa6d721 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,88 @@ -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). +# Backerup Website -## Getting Started +The official website and sharing hub for [Backerup](https://github.com/evan-buss/backerup) — a self-hosted Docker container backup tool. -First, run the development server: +Browse community-shared container configs and `.backerup` snapshots, upload your own, and copy restore commands in seconds. + +## Features + +- **Browse & search** — filter shared files by type, group, name, image, or description +- **Upload & share** — share `.backerup` snapshots and `.json` container configs via a unique link +- **One-click restore snippet** — Docker run commands generated from uploaded configs +- **Authentication** — JWT-based sessions with scrypt password hashing and rate-limited login +- **Admin panel** — create, delete, and manage user accounts with role-based access +- **Security headers** — `X-Frame-Options`, `HSTS`, `CSP`-ready, and more on every response + +## Quick Start (Development) + +**Prerequisites:** [Bun](https://bun.sh) >= 1.0 ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +cd frontend +bun install +bun run dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +The site is available at . -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +> Auth is **disabled** in dev mode when `AUTH_SECRET` is not set — all routes are open. +> Set `AUTH_SECRET` and `ADMIN_PASSWORD` to test the full login flow locally. -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. +## Deployment -## Learn More +See [SETUP.md](./SETUP.md) for full Docker deployment instructions, environment variable reference, and first-login steps. -To learn more about Next.js, take a look at the following resources: +## Project Structure -- [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. +``` +frontend/ + app/ + page.tsx # Landing page + browse/ # Browse & search shared files + share/ # Upload form + files/[token]/ # Individual file detail + download + login/ # Login page + admin/ # Admin user management panel + api/ + auth/ # POST /login, POST /logout + files/ # GET file list, GET/download by token + upload/ # POST file upload + admin/users/ # Admin user CRUD + components/ # AnimateIn, CountUp, SpotlightCard, TerminalTyper, LogoutButton + lib/ + auth.ts # scrypt password hashing + session.ts # JWT session create/verify/delete + users.ts # User CRUD (stored in data/users.json) + store.ts # File metadata + upload storage + links.ts # Centralised external URL config + data/ # Runtime data (gitignored except .gitkeep) + meta.json # File record index + users.json # User accounts (hashed credentials only) + uploads/ # Uploaded files organised by token +docker/ + Dockerfile # Multi-stage build (deps → builder → slim runner) + docker-compose.yml # Single-service compose with named volume + build.sh # Build helper + deploy.sh # Deploy helper +``` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Link Configuration -## Deploy on Vercel +All external URLs (GitHub, Docker image, docs) live in one place: -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. +``` +frontend/lib/links.ts +``` -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Edit that file and every button, footer link, and terminal snippet updates automatically. + +## Tech Stack + +| Layer | Technology | +|---|---| +| Framework | Next.js 16 (App Router, standalone output) | +| Runtime | Bun | +| Styling | Tailwind CSS v4 + custom CSS animations | +| Auth | scrypt (Node crypto) + HS256 JWT (jose) | +| Storage | Local filesystem (JSON index + raw files) | +| Container | Docker multi-stage, non-root user | diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..54f9c25 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,162 @@ +# Setup Guide + +This guide covers deploying Backerup Website with Docker, configuring authentication, and managing users. + +--- + +## Table of Contents + +1. [Environment Variables](#environment-variables) +2. [Docker Compose (recommended)](#docker-compose-recommended) +3. [Docker Run](#docker-run) +4. [Building the Image](#building-the-image) +5. [First Login](#first-login) +6. [Managing Users](#managing-users) +7. [Development Mode](#development-mode) + +--- + +## Environment Variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `AUTH_SECRET` | **Yes (production)** | — | Secret used to sign session JWTs. Must be at least 32 characters. Generate with `openssl rand -base64 32`. | +| `ADMIN_PASSWORD` | First run only | — | Password for the initial `admin` account. Only used when no users exist yet. Safe to remove after first login. | +| `ADMIN_USERNAME` | No | `admin` | Username for the auto-seeded admin account. | +| `NODE_ENV` | No | `development` | Set to `production` in all deployed environments. | +| `NEXT_TELEMETRY_DISABLED` | No | — | Set to `1` to disable Next.js telemetry. | +| `PORT` | No | `3000` | Port the server listens on inside the container. | + +> **Security note:** `AUTH_SECRET` is required in production. If it is missing the app will throw at startup. Auth is intentionally bypassed in development when `AUTH_SECRET` is unset so you can work without logging in. + +--- + +## Docker Compose (recommended) + +Create a `docker-compose.yml` (or copy from `docker/docker-compose.yml`) and add the auth environment variables: + +```yaml +services: + backerup-website: + image: backerup-website:latest + container_name: backerup-website + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - backerup_data:/app/data + environment: + - NODE_ENV=production + - NEXT_TELEMETRY_DISABLED=1 + - AUTH_SECRET= # required + - ADMIN_PASSWORD= # first run only + +volumes: + backerup_data: + driver: local +``` + +Start the service: + +```bash +docker compose up -d +``` + +After the first successful login you can remove `ADMIN_PASSWORD` from the compose file and restart: + +```bash +docker compose up -d +``` + +--- + +## Docker Run + +```bash +docker run -d \ + --name backerup-website \ + --restart unless-stopped \ + -p 3000:3000 \ + -v backerup_data:/app/data \ + -e NODE_ENV=production \ + -e AUTH_SECRET="$(openssl rand -base64 32)" \ + -e ADMIN_PASSWORD=changeme \ + backerup-website:latest +``` + +--- + +## Building the Image + +From the repository root: + +```bash +# Using the helper script +bash docker/build.sh + +# Or directly +docker build -f docker/Dockerfile -t backerup-website:latest . +``` + +The Dockerfile uses a three-stage build: + +1. **deps** — installs Node/Bun dependencies with a frozen lockfile +2. **builder** — runs `bun run build` (Next.js standalone output) +3. **runner** — slim `oven/bun:1-slim` image, non-root `nextjs` user, only the compiled output + +--- + +## First Login + +1. Start the container with `ADMIN_PASSWORD` set. +2. Open . +3. Sign in with username `admin` (or your `ADMIN_USERNAME`) and the password you set. +4. The account is created on the first login attempt if no users exist yet. +5. Go to **Admin → User Management** to create additional accounts. +6. Once your accounts are set up, remove `ADMIN_PASSWORD` from your environment and restart. + +--- + +## Managing Users + +The admin panel is at `/admin` and is only visible to accounts with the `admin` role. + +From there you can: + +- **Create users** — set a username, password (min 8 characters), and role (`user` or `admin`) +- **Reset passwords** — enter a new password in the field next to any user and click **Reset pw** +- **Delete users** — click the trash icon. You cannot delete your own account, and the last admin account cannot be deleted. + +User data (hashed credentials only — no plaintext passwords) is stored in `data/users.json` inside the Docker volume. + +--- + +## Development Mode + +```bash +cd frontend +bun install +bun run dev +``` + +To test auth locally, pass both variables: + +```bash +AUTH_SECRET="dev-secret-at-least-32-characters!!" ADMIN_PASSWORD=admin123 bun run dev +``` + +Then visit and sign in with `admin` / `admin123`. + +--- + +## Data Persistence + +All runtime data lives under `/app/data` in the container: + +| Path | Contents | +|---|---| +| `/app/data/meta.json` | File record index (name, token, type, metadata) | +| `/app/data/users.json` | User accounts (hashed passwords only) | +| `/app/data/uploads//` | Uploaded files, organised by share token | + +Mount a named Docker volume (or a host directory) to `/app/data` to persist data across container restarts and upgrades. diff --git a/frontend/app/admin/UsersClient.tsx b/frontend/app/admin/UsersClient.tsx new file mode 100644 index 0000000..855daa6 --- /dev/null +++ b/frontend/app/admin/UsersClient.tsx @@ -0,0 +1,301 @@ +'use client' + +import { useState, useCallback } from 'react' +import type { PublicUser, Role } from '@/lib/users' + +interface Props { + initialUsers: PublicUser[] + currentUserId: string +} + +export default function UsersClient({ initialUsers, currentUserId }: Props) { + const [users, setUsers] = useState(initialUsers) + const [error, setError] = useState('') + const [successMsg, setSuccessMsg] = useState('') + + // Create user form state + const [creating, setCreating] = useState(false) + const [createUsername, setCreateUsername] = useState('') + const [createPassword, setCreatePassword] = useState('') + const [createRole, setCreateRole] = useState('user') + const [createLoading, setCreateLoading] = useState(false) + + // Password reset state — maps userId → new password string + const [pwReset, setPwReset] = useState>({}) + const [pwLoading, setPwLoading] = useState(null) + + function flash(msg: string) { + setSuccessMsg(msg) + setTimeout(() => setSuccessMsg(''), 3000) + } + + const refreshUsers = useCallback(async () => { + const res = await fetch('/api/admin/users') + if (res.ok) { + setUsers((await res.json()) as PublicUser[]) + } + }, []) + + async function handleCreate(e: React.FormEvent) { + e.preventDefault() + setCreateLoading(true) + setError('') + const res = await fetch('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: createUsername, password: createPassword, role: createRole }), + }) + const data = (await res.json()) as { error?: string } + setCreateLoading(false) + if (!res.ok) { + setError(data.error ?? 'Failed to create user') + return + } + setCreateUsername('') + setCreatePassword('') + setCreateRole('user') + setCreating(false) + await refreshUsers() + flash('User created successfully') + } + + async function handleDelete(id: string, username: string) { + if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return + setError('') + const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' }) + const data = (await res.json()) as { error?: string } + if (!res.ok) { + setError(data.error ?? 'Failed to delete user') + return + } + await refreshUsers() + flash(`User "${username}" deleted`) + } + + async function handlePasswordReset(id: string) { + const pw = pwReset[id] ?? '' + if (pw.length < 8) { + setError('New password must be at least 8 characters') + return + } + setError('') + setPwLoading(id) + const res = await fetch(`/api/admin/users/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: pw }), + }) + const data = (await res.json()) as { error?: string } + setPwLoading(null) + if (!res.ok) { + setError(data.error ?? 'Failed to update password') + return + } + setPwReset((prev) => ({ ...prev, [id]: '' })) + flash('Password updated') + } + + const inputStyle: React.CSSProperties = { + background: 'rgba(255,255,255,0.04)', + border: '1px solid rgba(255,255,255,0.1)', + color: '#e2e8f0', + borderRadius: '0.625rem', + padding: '0.5rem 0.75rem', + fontSize: '0.875rem', + outline: 'none', + width: '100%', + } + + return ( +
+ {/* Flash messages */} + {successMsg && ( +
+ + {successMsg} +
+ )} + {error && ( +
+ + {error} +
+ )} + + {/* Header + create button */} +
+

{users.length} user{users.length !== 1 ? 's' : ''}

+ +
+ + {/* Create user form */} + {creating && ( +
+

New User

+
+
+ + setCreateUsername(e.target.value)} + required + placeholder="username" + style={inputStyle} + /> +
+
+ + setCreatePassword(e.target.value)} + required + placeholder="••••••••" + style={inputStyle} + /> +
+
+ + +
+
+
+ + +
+
+ )} + + {/* User list */} +
+ {users.map((user) => ( +
+
+
+ {/* Avatar */} +
+ {user.username[0].toUpperCase()} +
+
+
+ {user.username} + {user.id === currentUserId && ( + you + )} +
+
+ + {user.role} + + + Joined {new Date(user.createdAt).toLocaleDateString()} + +
+
+
+ + {/* Delete (not self) */} + {user.id !== currentUserId && ( + + )} +
+ + {/* Password reset */} +
+ setPwReset((prev) => ({ ...prev, [user.id]: e.target.value }))} + placeholder="New password (min 8 chars)" + style={{ ...inputStyle, flex: 1 }} + /> + +
+
+ ))} + + {users.length === 0 && ( +

No users yet.

+ )} +
+
+ ) +} diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx new file mode 100644 index 0000000..01cfe14 --- /dev/null +++ b/frontend/app/admin/page.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/session' +import { listUsers } from '@/lib/users' +import UsersClient from './UsersClient' + +export const metadata: Metadata = { + title: 'Admin — Backerup', +} + +export default async function AdminPage() { + const session = await getSession() + if (!session || session.role !== 'admin') { + redirect('/') + } + + const users = await listUsers() + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

User Management

+

Manage accounts and permissions

+
+
+
+ + +
+ ) +} diff --git a/frontend/app/api/admin/users/[id]/route.ts b/frontend/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..4113b90 --- /dev/null +++ b/frontend/app/api/admin/users/[id]/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server' +import { deleteUser, updatePassword } from '@/lib/users' +import { getSession } from '@/lib/session' + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getSession() + if (!session || session.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const { id } = await params + + // Prevent admins from deleting themselves + if (id === session.userId) { + return NextResponse.json({ error: 'You cannot delete your own account' }, { status: 400 }) + } + + try { + await deleteUser(id) + return NextResponse.json({ ok: true }) + } catch (e) { + return NextResponse.json({ error: (e as Error).message }, { status: 400 }) + } +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getSession() + if (!session || session.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const { id } = await params + + let body: { password?: string } + try { + body = (await request.json()) as { password?: string } + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + const password = String(body.password ?? '') + + try { + await updatePassword(id, password) + return NextResponse.json({ ok: true }) + } catch (e) { + return NextResponse.json({ error: (e as Error).message }, { status: 400 }) + } +} diff --git a/frontend/app/api/admin/users/route.ts b/frontend/app/api/admin/users/route.ts new file mode 100644 index 0000000..2af5ff9 --- /dev/null +++ b/frontend/app/api/admin/users/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server' +import { listUsers, createUser } from '@/lib/users' +import { getSession } from '@/lib/session' + +export async function GET() { + const session = await getSession() + if (!session || session.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const users = await listUsers() + return NextResponse.json(users) +} + +export async function POST(request: Request) { + const session = await getSession() + if (!session || session.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + let body: { username?: string; password?: string; role?: string } + try { + body = (await request.json()) as { username?: string; password?: string; role?: string } + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + const username = String(body.username ?? '').trim() + const password = String(body.password ?? '') + const role = body.role === 'admin' ? 'admin' : ('user' as const) + + if (!username || !password) { + return NextResponse.json({ error: 'Username and password are required' }, { status: 400 }) + } + + try { + const user = await createUser(username, password, role) + return NextResponse.json(user, { status: 201 }) + } catch (e) { + return NextResponse.json({ error: (e as Error).message }, { status: 409 }) + } +} diff --git a/frontend/app/api/auth/login/route.ts b/frontend/app/api/auth/login/route.ts new file mode 100644 index 0000000..e0ee425 --- /dev/null +++ b/frontend/app/api/auth/login/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from 'next/server' +import { authenticateUser, seedAdminIfEmpty } from '@/lib/users' +import { createSession } from '@/lib/session' + +// ── In-memory rate limiter (single-instance, resets on restart) ────────────── +interface RateLimitEntry { + attempts: number + lockedUntil: number +} +const rateLimitMap = new Map() +const MAX_ATTEMPTS = 5 +const LOCKOUT_MS = 15 * 60 * 1000 // 15 minutes + +function getClientIp(req: Request): string { + return ( + req.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? + req.headers.get('x-real-ip') ?? + 'unknown' + ) +} + +function checkRateLimit(ip: string): { allowed: boolean; retryAfterSecs?: number } { + const now = Date.now() + const entry = rateLimitMap.get(ip) + if (!entry) return { allowed: true } + if (entry.lockedUntil > now) { + return { allowed: false, retryAfterSecs: Math.ceil((entry.lockedUntil - now) / 1000) } + } + // Lock has expired — reset + rateLimitMap.delete(ip) + return { allowed: true } +} + +function recordFailedAttempt(ip: string): void { + const now = Date.now() + const entry = rateLimitMap.get(ip) ?? { attempts: 0, lockedUntil: 0 } + entry.attempts++ + if (entry.attempts >= MAX_ATTEMPTS) { + entry.lockedUntil = now + LOCKOUT_MS + entry.attempts = 0 + } + rateLimitMap.set(ip, entry) +} + +function clearAttempts(ip: string): void { + rateLimitMap.delete(ip) +} + +// ── Handler ─────────────────────────────────────────────────────────────────── +export async function POST(request: Request) { + const ip = getClientIp(request) + const { allowed, retryAfterSecs } = checkRateLimit(ip) + + if (!allowed) { + return NextResponse.json( + { error: `Too many attempts. Try again in ${retryAfterSecs} seconds.` }, + { + status: 429, + headers: { 'Retry-After': String(retryAfterSecs) }, + }, + ) + } + + let body: { username?: string; password?: string } + try { + body = (await request.json()) as { username?: string; password?: string } + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + const username = String(body.username ?? '').trim() + const password = String(body.password ?? '') + + if (!username || !password) { + return NextResponse.json({ error: 'Username and password are required' }, { status: 400 }) + } + + // Seed admin on first login attempt if no users exist yet + await seedAdminIfEmpty() + + const user = await authenticateUser(username, password) + if (!user) { + recordFailedAttempt(ip) + // Return generic error — don't reveal whether the username exists + return NextResponse.json({ error: 'Invalid username or password' }, { status: 401 }) + } + + clearAttempts(ip) + await createSession({ userId: user.id, username: user.username, role: user.role }) + return NextResponse.json({ ok: true, username: user.username, role: user.role }) +} diff --git a/frontend/app/api/auth/logout/route.ts b/frontend/app/api/auth/logout/route.ts new file mode 100644 index 0000000..e049b32 --- /dev/null +++ b/frontend/app/api/auth/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server' +import { deleteSession } from '@/lib/session' + +export async function POST() { + await deleteSession() + return NextResponse.json({ ok: true }) +} diff --git a/frontend/app/api/files/route.ts b/frontend/app/api/files/route.ts index 3e21e33..29b65d1 100644 --- a/frontend/app/api/files/route.ts +++ b/frontend/app/api/files/route.ts @@ -1,7 +1,13 @@ import { NextResponse } from 'next/server' import { readMeta } from '@/lib/store' +import { getSession } from '@/lib/session' export async function GET() { + // Belt-and-suspenders auth check (middleware handles the primary check) + const session = await getSession() + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } const records = await readMeta() return NextResponse.json(records) } diff --git a/frontend/app/api/upload/route.ts b/frontend/app/api/upload/route.ts index 86096fd..369a748 100644 --- a/frontend/app/api/upload/route.ts +++ b/frontend/app/api/upload/route.ts @@ -1,12 +1,18 @@ import { NextResponse } from 'next/server' import { nanoid } from 'nanoid' import { addFile } from '@/lib/store' +import { getSession } from '@/lib/session' import type { FileRecord } from '@/lib/store' const ALLOWED_EXTENSIONS = ['.backerup', '.json'] const MAX_SIZE_BYTES = 500 * 1024 * 1024 // 500 MB export async function POST(request: Request) { + // Belt-and-suspenders auth check (middleware handles the primary check) + const session = await getSession() + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } let formData: FormData try { formData = await request.formData() diff --git a/frontend/app/browse/BrowseClient.tsx b/frontend/app/browse/BrowseClient.tsx index c223dc9..4f22942 100644 --- a/frontend/app/browse/BrowseClient.tsx +++ b/frontend/app/browse/BrowseClient.tsx @@ -1,47 +1,317 @@ "use client"; -import { useRouter } from "next/navigation"; +import { useState, useMemo } from "react"; +import Link from "next/link"; +import type { FileRecord } from "@/lib/store"; + +type SortOrder = "newest" | "oldest" | "name" | "size-desc" | "size-asc"; +type TypeFilter = "all" | "backerup" | "config"; + +function formatBytes(n: number) { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(2)} MB`; +} + +function formatDate(s: string) { + return new Date(s).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function SearchIcon() { + return ( + + ); +} + +function ArrowIcon() { + return ( + + ); +} + +function XIcon() { + return ( + + ); +} + +function SortIcon() { + return ( + + ); +} export default function BrowseClient({ - groups, - selectedGroup, + records: allRecords, + groups, }: { - groups: string[]; - selectedGroup: string | null; + records: FileRecord[]; + groups: string[]; }) { - const router = useRouter(); + const [search, setSearch] = useState(""); + const [selectedGroup, setSelectedGroup] = useState(null); + const [typeFilter, setTypeFilter] = useState("all"); + const [sort, setSort] = useState("newest"); - function select(g: string | null) { - if (g === null) { - router.push("/browse"); - } else { - router.push(`/browse?group=${encodeURIComponent(g)}`); - } + const filtered = useMemo(() => { + let r = allRecords; + + if (search.trim()) { + const q = search.toLowerCase(); + r = r.filter( + (rec) => + rec.displayName?.toLowerCase().includes(q) || + rec.originalName.toLowerCase().includes(q) || + rec.description?.toLowerCase().includes(q) || + rec.containerName?.toLowerCase().includes(q) || + rec.image?.toLowerCase().includes(q) || + rec.group?.toLowerCase().includes(q) + ); } - return ( -
+ if (selectedGroup) r = r.filter((rec) => rec.group === selectedGroup); + if (typeFilter !== "all") r = r.filter((rec) => rec.type === typeFilter); + + const sorted = [...r]; + switch (sort) { + case "newest": + sorted.sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime()); + break; + case "oldest": + sorted.sort((a, b) => new Date(a.uploadedAt).getTime() - new Date(b.uploadedAt).getTime()); + break; + case "name": + sorted.sort((a, b) => + (a.displayName ?? a.originalName).localeCompare(b.displayName ?? b.originalName) + ); + break; + case "size-desc": + sorted.sort((a, b) => b.size - a.size); + break; + case "size-asc": + sorted.sort((a, b) => a.size - b.size); + break; + } + + return sorted; + }, [allRecords, search, selectedGroup, typeFilter, sort]); + + const activeFilters = + (search.trim() ? 1 : 0) + (selectedGroup ? 1 : 0) + (typeFilter !== "all" ? 1 : 0); + + function clearAll() { + setSearch(""); + setSelectedGroup(null); + setTypeFilter("all"); + setSort("newest"); + } + + return ( +
+ {/* ── Search + Sort row ── */} +
+
+ + + + setSearch(e.target.value)} + className="w-full rounded-xl border border-white/8 bg-white/4 pl-10 pr-4 py-2.5 text-sm text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500/40 focus:bg-white/6 transition-all" + /> + {search && ( - {groups.map((g) => ( - - ))} + )}
- ); + +
+ + + + +
+
+ + {/* ── Filter pills ── */} +
+ {/* Type pills */} + {(["all", "backerup", "config"] as TypeFilter[]).map((t) => ( + + ))} + + {groups.length > 0 && ( + <> +
+ {groups.map((g) => ( + + ))} + + )} + + {activeFilters > 0 && ( + + )} +
+ + {/* ── Results count ── */} +

+ {filtered.length === allRecords.length + ? `${allRecords.length} file${allRecords.length !== 1 ? "s" : ""} shared by the community` + : `${filtered.length} of ${allRecords.length} files`} +

+ + {/* ── Cards ── */} + {filtered.length === 0 ? ( +
+
🔍
+

No results found

+

Try adjusting your search or clearing some filters.

+ {activeFilters > 0 && ( + + )} +
+ ) : ( +
+ {filtered.map((r) => ( + +
+
+ {/* Badges */} +
+ {r.type === "backerup" ? ( + + .backerup + + ) : ( + + .json config + + )} + {r.group && ( + + {r.group} + + )} +
+ + {/* Title */} + {(r.displayName ?? r.containerName) ? ( +

+ {r.displayName ?? r.containerName} +

+ ) : ( +

+ {r.originalName} +

+ )} + + {/* Image */} + {r.image && ( +

{r.image}

+ )} + + {/* Description */} + {r.description && ( +

+ {r.description} +

+ )} +
+ + {/* Arrow */} + + + +
+ + {/* Meta row */} +
+ {formatBytes(r.size)} + · + {formatDate(r.uploadedAt)} + {r.containerName && r.displayName && ( + <> + · + {r.containerName} + + )} +
+ + ))} +
+ )} +
+ ); } diff --git a/frontend/app/browse/page.tsx b/frontend/app/browse/page.tsx index 9c4dcce..4d374b6 100644 --- a/frontend/app/browse/page.tsx +++ b/frontend/app/browse/page.tsx @@ -1,112 +1,37 @@ import Link from "next/link"; import { readMeta, listGroups } from "@/lib/store"; -import type { FileRecord } from "@/lib/store"; import BrowseClient from "./BrowseClient"; -function formatBytes(n: number) { - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; - return `${(n / 1024 / 1024).toFixed(2)} MB`; -} - -function TypeBadge({ type }: { type: FileRecord["type"] }) { - return type === "backerup" ? ( - - .backerup - - ) : ( - - .json config - - ); -} - -export default async function BrowsePage({ - searchParams, -}: { - searchParams: Promise<{ group?: string }>; -}) { - const { group: selectedGroup } = await searchParams; +export default async function BrowsePage() { const [allRecords, groups] = await Promise.all([readMeta(), listGroups()]); - const records = selectedGroup - ? allRecords.filter((r) => r.group === selectedGroup) - : allRecords; - return (
+ {/* Header */}
-

Browse shared files

+

+ Community +

+

+ Browse shared files +

- {records.length} file{records.length !== 1 ? "s" : ""} - {selectedGroup ? ` in "${selectedGroup}"` : " shared by the community"}. + Docker configs and backups shared by the community. Find, inspect, and restore in + seconds.

+ Share a file
- {/* Group filter bar */} - {groups.length > 0 && ( - - )} - - {records.length === 0 ? ( -
-

📭

-

- {selectedGroup ? `No files in group "${selectedGroup}".` : "No files shared yet."} -

- - Be the first to share one → - -
- ) : ( -
- {records.map((r) => ( - -
-
-
- - {r.group && ( - - {r.group} - - )} - {r.originalName} -
- {(r.displayName ?? r.containerName) && ( -

- {r.displayName ?? r.containerName} -

- )} - {r.image && ( -

{r.image}

- )} - {r.description && ( -

{r.description}

- )} -
- -
-
- {formatBytes(r.size)} - {new Date(r.uploadedAt).toLocaleDateString()} -
- - ))} -
- )} + {/* Client section: search + filter + cards */} +
); } diff --git a/frontend/app/components/AnimateIn.tsx b/frontend/app/components/AnimateIn.tsx new file mode 100644 index 0000000..f99e782 --- /dev/null +++ b/frontend/app/components/AnimateIn.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +type Variant = + | "fade-up" + | "fade-down" + | "fade-in" + | "fade-left" + | "fade-right" + | "scale-up"; + +interface AnimateInProps { + children: React.ReactNode; + variant?: Variant; + /** Delay (ms) after the element enters the viewport before animating */ + delay?: number; + /** Animation duration in ms */ + duration?: number; + /** Extra classes to put on the wrapper div */ + className?: string; + /** If true (default) the animation only fires once */ + once?: boolean; +} + +const transforms: Record = { + "fade-up": { hidden: "opacity-0 translate-y-10", visible: "opacity-100 translate-y-0" }, + "fade-down": { hidden: "opacity-0 -translate-y-10", visible: "opacity-100 translate-y-0" }, + "fade-in": { hidden: "opacity-0", visible: "opacity-100" }, + "fade-left": { hidden: "opacity-0 -translate-x-10", visible: "opacity-100 translate-x-0" }, + "fade-right": { hidden: "opacity-0 translate-x-10", visible: "opacity-100 translate-x-0" }, + "scale-up": { hidden: "opacity-0 scale-95", visible: "opacity-100 scale-100" }, +}; + +export default function AnimateIn({ + children, + variant = "fade-up", + delay = 0, + duration = 650, + className = "", + once = true, +}: AnimateInProps) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + // Respect the user's motion preference + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + setVisible(true); + return; + } + + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + if (once) observer.disconnect(); + } else if (!once) { + setVisible(false); + } + }, + { threshold: 0.08, rootMargin: "0px 0px -60px 0px" }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [once]); + + const { hidden, visible: vis } = transforms[variant]; + + return ( +
+ {children} +
+ ); +} diff --git a/frontend/app/components/CountUp.tsx b/frontend/app/components/CountUp.tsx new file mode 100644 index 0000000..f96bb0e --- /dev/null +++ b/frontend/app/components/CountUp.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +interface CountUpProps { + /** Target number to count up to */ + to: number; + /** String appended after the number (e.g. "%", " MB", "+") */ + suffix?: string; + /** String prepended before the number */ + prefix?: string; + /** Animation duration in ms */ + duration?: number; + /** Decimal places to show */ + decimals?: number; +} + +export default function CountUp({ + to, + suffix = "", + prefix = "", + duration = 1800, + decimals = 0, +}: CountUpProps) { + const ref = useRef(null); + const [current, setCurrent] = useState(0); + const [started, setStarted] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !started) { + setStarted(true); + observer.disconnect(); + } + }, + { threshold: 0.5 }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [started]); + + useEffect(() => { + if (!started) return; + + // Skip animation if user prefers reduced motion + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + setCurrent(to); + return; + } + + const startTime = performance.now(); + let raf: number; + + function tick(now: number) { + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + // Ease-out quart — fast start, smooth finish + const eased = 1 - Math.pow(1 - progress, 4); + setCurrent(eased * to); + if (progress < 1) raf = requestAnimationFrame(tick); + } + + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [started, to, duration]); + + const display = + decimals > 0 ? current.toFixed(decimals) : Math.floor(current).toString(); + + return ( + + {prefix} + {display} + {suffix} + + ); +} diff --git a/frontend/app/components/LogoutButton.tsx b/frontend/app/components/LogoutButton.tsx new file mode 100644 index 0000000..febf2a2 --- /dev/null +++ b/frontend/app/components/LogoutButton.tsx @@ -0,0 +1,40 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useState } from 'react' + +export default function LogoutButton() { + const router = useRouter() + const [loading, setLoading] = useState(false) + + async function handleLogout() { + setLoading(true) + await fetch('/api/auth/logout', { method: 'POST' }) + router.push('/login') + router.refresh() + } + + return ( + + ) +} diff --git a/frontend/app/components/SpotlightCard.tsx b/frontend/app/components/SpotlightCard.tsx new file mode 100644 index 0000000..b2402cc --- /dev/null +++ b/frontend/app/components/SpotlightCard.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useRef } from "react"; + +/** + * SpotlightCard — renders a card with a cursor-following radial glow + * that lights up where the user's mouse is hovering. Zero dependencies. + */ +export default function SpotlightCard({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) { + const ref = useRef(null); + + function onMouseMove(e: React.MouseEvent) { + const el = ref.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + el.style.setProperty("--mx", `${e.clientX - rect.left}px`); + el.style.setProperty("--my", `${e.clientY - rect.top}px`); + } + + function onMouseLeave() { + // Reset so the glow fades away gracefully + const el = ref.current; + if (!el) return; + el.style.setProperty("--mx", "-9999px"); + el.style.setProperty("--my", "-9999px"); + } + + return ( +
+ {children} +
+ ); +} diff --git a/frontend/app/components/TerminalTyper.tsx b/frontend/app/components/TerminalTyper.tsx new file mode 100644 index 0000000..1c32e95 --- /dev/null +++ b/frontend/app/components/TerminalTyper.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { LINKS } from "@/lib/links"; + +interface Line { + type: "comment" | "cmd" | "cmd-cont" | "output" | "success" | "error"; + text: string; +} + +function buildLines(): Line[] { + return [ + { type: "comment", text: "# Pull the latest Backerup image" }, + { type: "cmd", text: `docker pull ${LINKS.dockerImage}` }, + { type: "output", text: `latest: Pulling from ${LINKS.dockerImage.split(":")[0]}` }, + { type: "output", text: "Digest: sha256:a3f8b1c2d9e4f7g6h5..." }, + { type: "success", text: "✓ Status: Image is up to date" }, + { type: "comment", text: "# Start Backerup with Docker socket access" }, + { type: "cmd", text: "docker run -d --name backerup \\" }, + { type: "cmd-cont", text: ` -p ${LINKS.uiPort}:${LINKS.uiPort} \\` }, + { type: "cmd-cont", text: " -v /var/run/docker.sock:/var/run/docker.sock \\" }, + { type: "cmd-cont", text: ` ${LINKS.dockerImage}` }, + { type: "success", text: "✓ Container started · a7f82c3d1b09" }, + ]; +} + +function lineSpeed(line: Line) { + if (line.type === "output" || line.type === "success" || line.type === "error") return 12; + if (line.type === "comment") return 22; + return 38; +} + +function linePause(line: Line) { + if (line.type === "success" || line.type === "error") return 700; + if (line.type === "output") return 80; + if (line.type === "comment") return 300; + return 350; +} + +function lineColor(line: Line) { + switch (line.type) { + case "comment": return "text-slate-600"; + case "success": return "text-emerald-400"; + case "error": return "text-red-400"; + case "output": return "text-slate-400"; + default: return "text-slate-200"; + } +} + +function linePrompt(line: Line) { + if (line.type === "cmd") return "$ "; + if (line.type === "cmd-cont") return " "; + return ""; +} + +export default function TerminalTyper() { + const LINES = buildLines(); + const [doneLines, setDoneLines] = useState([]); + const [current, setCurrent] = useState<{ idx: number; chars: number } | null>({ idx: 0, chars: 0 }); + + useEffect(() => { + if (!current) return; + + const { idx, chars } = current; + const line = LINES[idx]; + const fullLen = line.text.length; + + if (chars < fullLen) { + const t = setTimeout(() => { + setCurrent({ idx, chars: chars + 1 }); + }, lineSpeed(line)); + return () => clearTimeout(t); + } + + // Line finished → pause, then advance + const t = setTimeout(() => { + setDoneLines((prev) => [...prev, idx]); + const next = idx + 1; + if (next < LINES.length) { + setCurrent({ idx: next, chars: 0 }); + } else { + setCurrent(null); + } + }, linePause(line)); + return () => clearTimeout(t); + }, [current]); + + return ( +
+ {doneLines.map((i) => { + const line = LINES[i]; + const prompt = linePrompt(line); + return ( +
+ {prompt && {prompt}} + {line.text} +
+ ); + })} + + {current && current.idx < LINES.length && (() => { + const line = LINES[current.idx]; + const prompt = linePrompt(line); + const partial = line.text.slice(0, current.chars); + return ( +
+ {prompt && {prompt}} + {partial} + +
+ ); + })()} +
+ ); +} diff --git a/frontend/app/files/[token]/CopyLinkButton.tsx b/frontend/app/files/[token]/CopyLinkButton.tsx index f29c7d5..3fdba94 100644 --- a/frontend/app/files/[token]/CopyLinkButton.tsx +++ b/frontend/app/files/[token]/CopyLinkButton.tsx @@ -1,18 +1,55 @@ "use client"; +import { useState } from "react"; + export default function CopyLinkButton({ token }: { token: string }) { - function copy() { + const [copied, setCopied] = useState(false); + + async function copy() { const url = `${window.location.origin}/files/${token}`; - navigator.clipboard.writeText(url).then(() => { - alert("Link copied to clipboard!"); - }); + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2500); + } catch { + // Fallback for browsers that deny clipboard access + const el = document.createElement("textarea"); + el.value = url; + el.style.position = "fixed"; + el.style.opacity = "0"; + document.body.appendChild(el); + el.select(); + document.execCommand("copy"); + document.body.removeChild(el); + setCopied(true); + setTimeout(() => setCopied(false), 2500); + } } + return ( ); } diff --git a/frontend/app/files/[token]/page.tsx b/frontend/app/files/[token]/page.tsx index 7af9997..969cd42 100644 --- a/frontend/app/files/[token]/page.tsx +++ b/frontend/app/files/[token]/page.tsx @@ -9,6 +9,15 @@ function formatBytes(n: number) { return `${(n / 1024 / 1024).toFixed(2)} MB`; } +function MetaRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + export default async function FileDetailPage({ params, }: { @@ -20,98 +29,135 @@ export default async function FileDetailPage({ const isBackerup = record.type === "backerup"; const downloadUrl = `/api/files/${token}/download`; + const title = record.displayName ?? record.containerName ?? record.originalName; return (
- - ← Back to browse + {/* Back */} + + + Back to browse -
-
+ {/* Header card */} +
+ {/* Badges */} +
{isBackerup ? ( - - .backerup + + .backerup archive ) : ( - + .json config )} - {record.originalName} + {record.group && ( + + {record.group} + + )} + {record.originalName}
- {record.group && ( - - {record.group} - - )} -

- {record.displayName ?? record.containerName ?? record.originalName} + {/* Title */} +

+ {title}

+ + {/* Image */} {record.image && ( -

{record.image}

+

{record.image}

)} + + {/* Description */} {record.description && ( -

{record.description}

+

+ {record.description} +

)}
- {/* Metadata grid */} -
-
- File size - {formatBytes(record.size)} -
-
- Uploaded - - {new Date(record.uploadedAt).toLocaleString()} - -
- {record.group && ( -
- Group - {record.group} -
- )} - {record.containerName && ( -
- Container name - {record.containerName} -
- )} - {record.image && ( -
- Image - {record.image} -
- )} -
- Share token - {token} -
+ {/* Metadata */} +
+ + + {record.group && } + {record.containerName && } + {record.image && } +
+ {/* Docker restore snippet for configs */} + {!isBackerup && record.image && ( +
+
+ + + + quick restore +
+
+            # Pull the image used by this config{"\n"}
+            $ 
+            docker pull {record.image}{"\n\n"}
+            # Download & import this config into Backerup to restore{"\n"}
+            $ 
+            curl -O {`{{BACKERUP_URL}}`}/api/files/{token}/download
+          
+
+ )} + {/* Actions */} -
+
+ Download {record.originalName} - {/* Copy link */} + + + Browse more configs +
+ {/* Warning for .backerup */} {isBackerup && ( -
- Note: This is a full .backerup archive. It may contain volume data - in addition to the container config. Import it into Backerup to restore - or clone the container. +
+ + + +

+ Note: This is a full .backerup archive. It may contain volume data + in addition to the container config. Import it into Backerup to restore or clone the container. +

)}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 19ba9b1..8963e1a 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,19 +1,286 @@ @import "tailwindcss"; -:root { - --background: #0b0f1a; - --foreground: #e2e8f0; -} - @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); + --color-background: #060810; + --color-foreground: #e2e8f0; --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + + --animate-fade-in: fadeIn 0.8s ease-out both; + --animate-slide-up: slideUp 0.8s ease-out both; + --animate-glow: glow 3s ease-in-out infinite alternate; + --animate-float: float 6s ease-in-out infinite; + --animate-gradient: gradientShift 8s ease infinite; + --animate-blink: blink 1s step-end infinite; + --animate-spin-slow: spinSlow 25s linear infinite; + --animate-pulse-slow: pulseSlow 5s ease-in-out infinite; + --animate-shimmer: shimmerSlide 3s ease infinite; + --animate-scan: scanLine 4s linear infinite; + --animate-aurora-1: aurora1 18s ease-in-out infinite alternate; + --animate-aurora-2: aurora2 23s ease-in-out infinite alternate-reverse; + --animate-aurora-3: aurora3 15s ease-in-out infinite alternate; + --animate-border-glow: borderGlow 3s ease-in-out infinite alternate; +} + +:root { + --background: #060810; + --foreground: #e2e8f0; } body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(32px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes glow { + from { + box-shadow: 0 0 20px rgba(6, 182, 212, 0.15), 0 0 40px rgba(6, 182, 212, 0.05); + } + to { + box-shadow: 0 0 40px rgba(6, 182, 212, 0.45), 0 0 80px rgba(6, 182, 212, 0.2), 0 0 120px rgba(6, 182, 212, 0.05); + } +} + +@keyframes float { + 0%, 100% { transform: translateY(0px); } + 33% { transform: translateY(-14px); } + 66% { transform: translateY(-7px); } +} + +@keyframes gradientShift { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +@keyframes spinSlow { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes pulseSlow { + 0%, 100% { opacity: 0.12; transform: scale(0.97); } + 50% { opacity: 0.35; transform: scale(1.03); } +} + +@keyframes shimmerSlide { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(200%); } +} + +@keyframes scanLine { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(500%); } +} + +/* ── Glassmorphism ─────────────────────────────────────────── */ +.glass { + background: rgba(255, 255, 255, 0.025); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.glass-heavy { + background: rgba(6, 8, 16, 0.85); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +/* ── Gradient text ─────────────────────────────────────────── */ +.gradient-text { + background: linear-gradient(135deg, #22d3ee 0%, #a78bfa 55%, #22d3ee 100%); + background-size: 200% auto; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: gradientShift 8s ease infinite; +} + +/* ── Grid background ───────────────────────────────────────── */ +.grid-bg { + background-image: + linear-gradient(rgba(6, 182, 212, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(6, 182, 212, 0.035) 1px, transparent 1px); + background-size: 48px 48px; +} + +/* ── Card hover glow ───────────────────────────────────────── */ +.card-hover { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.card-hover:hover { + transform: translateY(-3px); + border-color: rgba(6, 182, 212, 0.3) !important; + box-shadow: + 0 0 0 1px rgba(6, 182, 212, 0.06), + 0 16px 48px rgba(0, 0, 0, 0.5), + 0 0 40px rgba(6, 182, 212, 0.08); +} + +/* ── Neon glow border ──────────────────────────────────────── */ +.neon-cyan { + box-shadow: + 0 0 0 1px rgba(6, 182, 212, 0.5), + 0 0 16px rgba(6, 182, 212, 0.2); +} + +/* ── Shimmer overlay ───────────────────────────────────────── */ +.shimmer { + position: relative; + overflow: hidden; +} +.shimmer::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 40%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.04), transparent); + animation: shimmerSlide 3s ease infinite; + pointer-events: none; +} + +/* ── Terminal scan effect ──────────────────────────────────── */ +.terminal-scan { + position: relative; + overflow: hidden; +} +.terminal-scan::before { + content: ''; + position: absolute; + left: 0; + right: 0; + height: 60px; + background: linear-gradient(to bottom, transparent, rgba(6, 182, 212, 0.025), transparent); + animation: scanLine 4s linear infinite; + pointer-events: none; + z-index: 1; +} + +/* ── Step connector line ───────────────────────────────────── */ +.step-connector { + position: relative; +} +.step-connector::after { + content: ''; + position: absolute; + top: 20px; + left: calc(100% + 0px); + width: calc(100% - 40px); + height: 1px; + background: linear-gradient(90deg, rgba(6, 182, 212, 0.4), rgba(6, 182, 212, 0.05)); +} + +/* ── Aurora blob keyframes ────────────────────────────────── */ +@keyframes aurora1 { + 0% { transform: translate( 0px, 0px) scale(1.00); } + 33% { transform: translate( 80px, -70px) scale(1.15); } + 66% { transform: translate(-50px, 55px) scale(0.90); } + 100% { transform: translate( 25px, -20px) scale(1.05); } +} + +@keyframes aurora2 { + 0% { transform: translate( 0px, 0px) scale(1.00); } + 33% { transform: translate(-75px, 65px) scale(0.88); } + 66% { transform: translate( 90px, -45px) scale(1.12); } + 100% { transform: translate(-25px, 30px) scale(0.95); } +} + +@keyframes aurora3 { + 0% { transform: translate( 0px, 0px) scale(1.00); } + 33% { transform: translate( 55px, 75px) scale(1.10); } + 66% { transform: translate(-65px, -55px) scale(0.92); } + 100% { transform: translate( 15px, 45px) scale(1.08); } +} + +/* ── Border glow pulse ─────────────────────────────────────── */ +@keyframes borderGlow { + from { box-shadow: 0 0 12px rgba(6,182,212,0.3), 0 0 24px rgba(6,182,212,0.1); } + to { box-shadow: 0 0 24px rgba(6,182,212,0.6), 0 0 60px rgba(6,182,212,0.25), 0 0 100px rgba(139,92,246,0.1); } +} + +/* ── Spotlight card ────────────────────────────────────────── */ +.spotlight { + --mx: -9999px; + --my: -9999px; + position: relative; +} + +.spotlight::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: radial-gradient( + 280px circle at var(--mx) var(--my), + rgba(6, 182, 212, 0.09), + transparent 70% + ); + opacity: 0; + transition: opacity 0.4s ease; + pointer-events: none; + z-index: 0; +} + +.spotlight:hover::before { + opacity: 1; +} + +.spotlight > * { + position: relative; + z-index: 1; +} + +/* ── Nav link underline animation ──────────────────────────── */ +.nav-link { + position: relative; +} + +.nav-link::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 0; + height: 1px; + background: linear-gradient(90deg, #22d3ee, #818cf8); + transition: width 0.3s cubic-bezier(0.22, 1, 0.36, 1); +} + +.nav-link:hover::after { + width: 100%; +} + +/* ── Scrollbar ─────────────────────────────────────────────── */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: #060810; +} +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); } \ No newline at end of file diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index b6ab99a..708aef7 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,6 +1,9 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import Link from "next/link"; +import { LINKS } from "@/lib/links"; +import { getSession } from "@/lib/session"; +import LogoutButton from "./components/LogoutButton"; import "./globals.css"; const geistSans = Geist({ @@ -19,32 +22,115 @@ export const metadata: Metadata = { "Simple, reliable Docker container backups. Share and discover container configs and backup snapshots.", }; -export default function RootLayout({ +function GitHubIcon({ className }: { className?: string }) { + return ( + + ); +} + +function DockerIcon({ className }: { className?: string }) { + return ( + + ); +} + +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const session = await getSession(); return ( - - {/* Nav */} -
+ + {/* ── Global ambient background ── */} +
+
+
+
+
+
+ + {/* ── Nav ── */} +
- - - .backerup + +
+ +
+ + Backerup - Backerup -
- {/* Page content */} + {/* ── Page content ── */}
{children}
- {/* Footer */} -