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.
This commit is contained in:
Anders Böttcher
2026-05-12 14:58:11 +02:00
parent 50566f2a22
commit 0d58fafe4e
34 changed files with 4263 additions and 5012 deletions
+73 -21
View File
@@ -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 ```bash
npm run dev cd frontend
# or bun install
yarn dev bun run dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. The site is available at <http://localhost:3000>.
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 |
+162
View File
@@ -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=<your-random-secret-here> # required
- ADMIN_PASSWORD=<strong-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 <http://your-host:3000/login>.
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 <http://localhost:3000/login> 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/<token>/` | 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.
+301
View File
@@ -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<PublicUser[]>(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<Role>('user')
const [createLoading, setCreateLoading] = useState(false)
// Password reset state — maps userId → new password string
const [pwReset, setPwReset] = useState<Record<string, string>>({})
const [pwLoading, setPwLoading] = useState<string | null>(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 (
<div className="space-y-6">
{/* Flash messages */}
{successMsg && (
<div
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm"
style={{ background: 'rgba(34,197,94,0.1)', border: '1px solid rgba(34,197,94,0.25)', color: '#86efac' }}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0" aria-hidden="true">
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clipRule="evenodd" />
</svg>
{successMsg}
</div>
)}
{error && (
<div
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm"
style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)', color: '#fca5a5' }}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0" aria-hidden="true">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
{error}
</div>
)}
{/* Header + create button */}
<div className="flex items-center justify-between">
<p className="text-sm text-slate-400">{users.length} user{users.length !== 1 ? 's' : ''}</p>
<button
onClick={() => setCreating((v) => !v)}
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold text-slate-900 hover:brightness-110 transition-all active:scale-95"
style={{ background: 'linear-gradient(135deg, #22d3ee, #06b6d4)' }}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4" aria-hidden="true">
<path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
</svg>
Add User
</button>
</div>
{/* Create user form */}
{creating && (
<form
onSubmit={handleCreate}
className="rounded-2xl p-6 space-y-4"
style={{ background: 'rgba(34,211,238,0.04)', border: '1px solid rgba(34,211,238,0.15)' }}
>
<h3 className="text-sm font-semibold text-cyan-300">New User</h3>
<div className="grid sm:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="block text-xs text-slate-400">Username</label>
<input
type="text"
value={createUsername}
onChange={(e) => setCreateUsername(e.target.value)}
required
placeholder="username"
style={inputStyle}
/>
</div>
<div className="space-y-1">
<label className="block text-xs text-slate-400">Password (min 8 chars)</label>
<input
type="password"
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
required
placeholder="••••••••"
style={inputStyle}
/>
</div>
<div className="space-y-1">
<label className="block text-xs text-slate-400">Role</label>
<select
value={createRole}
onChange={(e) => setCreateRole(e.target.value as Role)}
style={{ ...inputStyle, cursor: 'pointer' }}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div className="flex gap-3 pt-1">
<button
type="submit"
disabled={createLoading}
className="px-5 py-2 rounded-xl text-sm font-semibold text-slate-900 hover:brightness-110 transition-all disabled:opacity-60"
style={{ background: 'linear-gradient(135deg, #22d3ee, #06b6d4)' }}
>
{createLoading ? 'Creating…' : 'Create User'}
</button>
<button
type="button"
onClick={() => setCreating(false)}
className="px-5 py-2 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
Cancel
</button>
</div>
</form>
)}
{/* User list */}
<div className="space-y-3">
{users.map((user) => (
<div
key={user.id}
className="rounded-2xl p-5"
style={{ background: 'rgba(255,255,255,0.025)', border: '1px solid rgba(255,255,255,0.07)' }}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
{/* Avatar */}
<div
className="w-9 h-9 rounded-xl flex items-center justify-center text-sm font-bold shrink-0"
style={{
background:
user.role === 'admin'
? 'linear-gradient(135deg, #a78bfa, #7c3aed)'
: 'linear-gradient(135deg, #22d3ee, #0891b2)',
color: '#0f172a',
}}
>
{user.username[0].toUpperCase()}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-white text-sm truncate">{user.username}</span>
{user.id === currentUserId && (
<span className="text-xs px-1.5 py-0.5 rounded-md bg-white/5 text-slate-400">you</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={
user.role === 'admin'
? { background: 'rgba(167,139,250,0.15)', color: '#c4b5fd' }
: { background: 'rgba(34,211,238,0.1)', color: '#67e8f9' }
}
>
{user.role}
</span>
<span className="text-xs text-slate-500">
Joined {new Date(user.createdAt).toLocaleDateString()}
</span>
</div>
</div>
</div>
{/* Delete (not self) */}
{user.id !== currentUserId && (
<button
onClick={() => handleDelete(user.id, user.username)}
className="shrink-0 p-2 rounded-lg text-slate-500 hover:text-red-400 hover:bg-red-400/10 transition-all"
title="Delete user"
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4" aria-hidden="true">
<path fillRule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
{/* Password reset */}
<div className="mt-4 flex gap-2 items-center">
<input
type="password"
value={pwReset[user.id] ?? ''}
onChange={(e) => setPwReset((prev) => ({ ...prev, [user.id]: e.target.value }))}
placeholder="New password (min 8 chars)"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => handlePasswordReset(user.id)}
disabled={pwLoading === user.id}
className="shrink-0 px-3 py-2 rounded-xl text-xs font-medium text-slate-300 hover:text-white hover:bg-white/10 transition-all disabled:opacity-50 border border-white/10"
>
{pwLoading === user.id ? 'Saving…' : 'Reset pw'}
</button>
</div>
</div>
))}
{users.length === 0 && (
<p className="text-center text-slate-500 text-sm py-8">No users yet.</p>
)}
</div>
</div>
)
}
+42
View File
@@ -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 (
<div className="mx-auto max-w-2xl px-4 py-12">
{/* Header */}
<div className="mb-10">
<div className="flex items-center gap-3 mb-2">
<div
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: 'linear-gradient(135deg, #a78bfa, #7c3aed)' }}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 text-white" aria-hidden="true">
<path d="M10 9a3 3 0 100-6 3 3 0 000 6zM6 8a2 2 0 11-4 0 2 2 0 014 0zM1.49 15.326a.78.78 0 01-.358-.442 3 3 0 014.308-3.516 6.484 6.484 0 00-1.905 3.959c-.023.222-.014.442.025.654a4.97 4.97 0 01-2.07-.655zM16.44 15.98a4.97 4.97 0 002.07-.654.78.78 0 00.357-.442 3 3 0 00-4.308-3.517 6.484 6.484 0 011.907 3.96 2.32 2.32 0 01-.026.654zM18 8a2 2 0 11-4 0 2 2 0 014 0zM5.304 16.19a.844.844 0 01-.277-.71 5 5 0 019.947 0 .843.843 0 01-.277.71A6.975 6.975 0 0110 18a6.974 6.974 0 01-4.696-1.81z" />
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-white">User Management</h1>
<p className="text-sm text-slate-400">Manage accounts and permissions</p>
</div>
</div>
</div>
<UsersClient initialUsers={users} currentUserId={session.userId} />
</div>
)
}
@@ -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 })
}
}
+41
View File
@@ -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 })
}
}
+91
View File
@@ -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<string, RateLimitEntry>()
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 })
}
+7
View File
@@ -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 })
}
+6
View File
@@ -1,7 +1,13 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { readMeta } from '@/lib/store' import { readMeta } from '@/lib/store'
import { getSession } from '@/lib/session'
export async function GET() { 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() const records = await readMeta()
return NextResponse.json(records) return NextResponse.json(records)
} }
+6
View File
@@ -1,12 +1,18 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { addFile } from '@/lib/store' import { addFile } from '@/lib/store'
import { getSession } from '@/lib/session'
import type { FileRecord } from '@/lib/store' import type { FileRecord } from '@/lib/store'
const ALLOWED_EXTENSIONS = ['.backerup', '.json'] const ALLOWED_EXTENSIONS = ['.backerup', '.json']
const MAX_SIZE_BYTES = 500 * 1024 * 1024 // 500 MB const MAX_SIZE_BYTES = 500 * 1024 * 1024 // 500 MB
export async function POST(request: Request) { 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 let formData: FormData
try { try {
formData = await request.formData() formData = await request.formData()
+288 -18
View File
@@ -1,47 +1,317 @@
"use client"; "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 (
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
);
}
function ArrowIcon() {
return (
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
);
}
function XIcon() {
return (
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path strokeLinecap="round" d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
function SortIcon() {
return (
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7h18M6 12h12M10 17h4" />
</svg>
);
}
export default function BrowseClient({ export default function BrowseClient({
records: allRecords,
groups, groups,
selectedGroup,
}: { }: {
records: FileRecord[];
groups: string[]; groups: string[];
selectedGroup: string | null;
}) { }) {
const router = useRouter(); const [search, setSearch] = useState("");
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const [sort, setSort] = useState<SortOrder>("newest");
function select(g: string | null) { const filtered = useMemo(() => {
if (g === null) { let r = allRecords;
router.push("/browse");
} else { if (search.trim()) {
router.push(`/browse?group=${encodeURIComponent(g)}`); 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)
);
} }
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 ( return (
<div className="mb-8 flex flex-wrap gap-2"> <div>
{/* ── Search + Sort row ── */}
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
<SearchIcon />
</span>
<input
type="search"
placeholder="Search by name, image, description, group…"
value={search}
onChange={(e) => 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 && (
<button <button
onClick={() => select(null)} onClick={() => setSearch("")}
className={`rounded-full px-4 py-1.5 text-xs font-semibold transition-colors ${selectedGroup === null className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
>
<XIcon />
</button>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-slate-600 shrink-0">
<SortIcon />
</span>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortOrder)}
className="rounded-xl border border-white/8 bg-slate-900 px-3 py-2.5 text-sm text-slate-300 outline-none focus:border-cyan-500/40 transition-all cursor-pointer"
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="name">Name AZ</option>
<option value="size-desc">Largest first</option>
<option value="size-asc">Smallest first</option>
</select>
</div>
</div>
{/* ── Filter pills ── */}
<div className="mb-8 flex flex-wrap items-center gap-2">
{/* Type pills */}
{(["all", "backerup", "config"] as TypeFilter[]).map((t) => (
<button
key={t}
onClick={() => setTypeFilter(t)}
className={`rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all ${
typeFilter === t
? t === "all"
? "bg-cyan-500 text-slate-900" ? "bg-cyan-500 text-slate-900"
: "border border-slate-700 bg-slate-800/50 text-slate-400 hover:border-slate-500 hover:text-slate-200" : t === "backerup"
? "bg-cyan-500/20 border border-cyan-500/50 text-cyan-300"
: "bg-violet-500/20 border border-violet-500/50 text-violet-300"
: "border border-white/8 bg-white/3 text-slate-500 hover:border-white/16 hover:text-slate-300"
}`} }`}
> >
All {t === "all" ? "All types" : t === "backerup" ? ".backerup" : ".json config"}
</button> </button>
))}
{groups.length > 0 && (
<>
<div className="h-5 w-px bg-white/8 mx-1" />
{groups.map((g) => ( {groups.map((g) => (
<button <button
key={g} key={g}
onClick={() => select(g)} onClick={() => setSelectedGroup(selectedGroup === g ? null : g)}
className={`rounded-full px-4 py-1.5 text-xs font-semibold transition-colors ${selectedGroup === g className={`rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all ${
? "bg-cyan-500 text-slate-900" selectedGroup === g
: "border border-slate-700 bg-slate-800/50 text-slate-400 hover:border-slate-500 hover:text-slate-200" ? "bg-violet-500/20 border border-violet-500/50 text-violet-300"
: "border border-white/8 bg-white/3 text-slate-500 hover:border-white/16 hover:text-slate-300"
}`} }`}
> >
{selectedGroup === g && (
<span className="mr-1 inline-block w-1.5 h-1.5 rounded-full bg-violet-400" />
)}
{g} {g}
</button> </button>
))} ))}
</>
)}
{activeFilters > 0 && (
<button
onClick={clearAll}
className="ml-auto flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
<XIcon />
Clear filters ({activeFilters})
</button>
)}
</div>
{/* ── Results count ── */}
<p className="mb-5 text-xs text-slate-600">
{filtered.length === allRecords.length
? `${allRecords.length} file${allRecords.length !== 1 ? "s" : ""} shared by the community`
: `${filtered.length} of ${allRecords.length} files`}
</p>
{/* ── Cards ── */}
{filtered.length === 0 ? (
<div className="rounded-2xl border border-white/5 glass p-20 text-center">
<div className="text-5xl mb-5">🔍</div>
<p className="font-semibold text-white">No results found</p>
<p className="mt-2 text-sm text-slate-500">Try adjusting your search or clearing some filters.</p>
{activeFilters > 0 && (
<button
onClick={clearAll}
className="mt-5 text-sm text-cyan-400 hover:text-cyan-300 transition-colors"
>
Clear all filters
</button>
)}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{filtered.map((r) => (
<Link
key={r.token}
href={`/files/${r.token}`}
className="group rounded-2xl border border-white/5 glass p-5 card-hover block"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{/* Badges */}
<div className="flex flex-wrap items-center gap-2">
{r.type === "backerup" ? (
<span className="rounded-full bg-cyan-500/12 border border-cyan-500/20 px-2.5 py-0.5 text-[11px] font-semibold text-cyan-400">
.backerup
</span>
) : (
<span className="rounded-full bg-violet-500/12 border border-violet-500/20 px-2.5 py-0.5 text-[11px] font-semibold text-violet-400">
.json config
</span>
)}
{r.group && (
<span className="rounded-full bg-white/4 border border-white/8 px-2.5 py-0.5 text-[11px] font-medium text-slate-400">
{r.group}
</span>
)}
</div>
{/* Title */}
{(r.displayName ?? r.containerName) ? (
<p className="mt-2.5 font-semibold text-white truncate group-hover:text-cyan-300 transition-colors">
{r.displayName ?? r.containerName}
</p>
) : (
<p className="mt-2.5 font-mono text-sm text-slate-400 truncate">
{r.originalName}
</p>
)}
{/* Image */}
{r.image && (
<p className="mt-0.5 font-mono text-xs text-slate-500 truncate">{r.image}</p>
)}
{/* Description */}
{r.description && (
<p className="mt-2 text-sm text-slate-400 line-clamp-2 leading-relaxed">
{r.description}
</p>
)}
</div>
{/* Arrow */}
<span className="shrink-0 text-slate-700 group-hover:text-cyan-400 group-hover:translate-x-1 transition-all mt-1">
<ArrowIcon />
</span>
</div>
{/* Meta row */}
<div className="mt-4 flex items-center gap-3 text-xs text-slate-600">
<span>{formatBytes(r.size)}</span>
<span className="text-slate-800">·</span>
<span>{formatDate(r.uploadedAt)}</span>
{r.containerName && r.displayName && (
<>
<span className="text-slate-800">·</span>
<span className="font-mono truncate">{r.containerName}</span>
</>
)}
</div>
</Link>
))}
</div>
)}
</div> </div>
); );
} }
+14 -89
View File
@@ -1,112 +1,37 @@
import Link from "next/link"; import Link from "next/link";
import { readMeta, listGroups } from "@/lib/store"; import { readMeta, listGroups } from "@/lib/store";
import type { FileRecord } from "@/lib/store";
import BrowseClient from "./BrowseClient"; import BrowseClient from "./BrowseClient";
function formatBytes(n: number) { export default async function BrowsePage() {
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" ? (
<span className="rounded-full bg-cyan-500/15 px-2.5 py-0.5 text-[11px] font-semibold text-cyan-400">
.backerup
</span>
) : (
<span className="rounded-full bg-violet-500/15 px-2.5 py-0.5 text-[11px] font-semibold text-violet-400">
.json config
</span>
);
}
export default async function BrowsePage({
searchParams,
}: {
searchParams: Promise<{ group?: string }>;
}) {
const { group: selectedGroup } = await searchParams;
const [allRecords, groups] = await Promise.all([readMeta(), listGroups()]); const [allRecords, groups] = await Promise.all([readMeta(), listGroups()]);
const records = selectedGroup
? allRecords.filter((r) => r.group === selectedGroup)
: allRecords;
return ( return (
<div className="mx-auto max-w-5xl px-6 py-16"> <div className="mx-auto max-w-5xl px-6 py-16">
{/* Header */}
<div className="mb-10 flex flex-wrap items-end justify-between gap-4"> <div className="mb-10 flex flex-wrap items-end justify-between gap-4">
<div> <div>
<h1 className="text-3xl font-bold text-white">Browse shared files</h1> <p className="text-xs font-semibold uppercase tracking-widest text-cyan-400 mb-2">
Community
</p>
<h1 className="text-4xl font-extrabold tracking-tight text-white">
Browse shared files
</h1>
<p className="mt-2 text-slate-400"> <p className="mt-2 text-slate-400">
{records.length} file{records.length !== 1 ? "s" : ""} Docker configs and backups shared by the community. Find, inspect, and restore in
{selectedGroup ? ` in "${selectedGroup}"` : " shared by the community"}. seconds.
</p> </p>
</div> </div>
<Link <Link
href="/share" href="/share"
className="rounded-xl bg-cyan-500 px-6 py-2.5 text-sm font-bold text-slate-900 hover:bg-cyan-400 transition-colors" className="rounded-xl px-6 py-2.5 text-sm font-bold text-slate-900 transition-all hover:brightness-110 hover:scale-105 active:scale-95 shrink-0"
style={{ background: "linear-gradient(135deg, #22d3ee, #06b6d4)" }}
> >
+ Share a file + Share a file
</Link> </Link>
</div> </div>
{/* Group filter bar */} {/* Client section: search + filter + cards */}
{groups.length > 0 && ( <BrowseClient records={allRecords} groups={groups} />
<BrowseClient groups={groups} selectedGroup={selectedGroup ?? null} />
)}
{records.length === 0 ? (
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-16 text-center">
<p className="text-4xl">📭</p>
<p className="mt-4 text-slate-400">
{selectedGroup ? `No files in group "${selectedGroup}".` : "No files shared yet."}
</p>
<Link href="/share" className="mt-4 inline-block text-sm text-cyan-400 hover:underline">
Be the first to share one
</Link>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{records.map((r) => (
<Link
key={r.token}
href={`/files/${r.token}`}
className="group rounded-2xl border border-slate-800 bg-slate-900/60 p-5 transition hover:border-slate-600 hover:bg-slate-900"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<TypeBadge type={r.type} />
{r.group && (
<span className="rounded-full bg-slate-700/60 px-2.5 py-0.5 text-[11px] font-medium text-slate-400">
{r.group}
</span>
)}
<span className="truncate font-mono text-xs text-slate-500">{r.originalName}</span>
</div>
{(r.displayName ?? r.containerName) && (
<p className="mt-2 font-semibold text-white truncate">
{r.displayName ?? r.containerName}
</p>
)}
{r.image && (
<p className="mt-0.5 text-xs text-slate-400 truncate">{r.image}</p>
)}
{r.description && (
<p className="mt-2 text-sm text-slate-400 line-clamp-2">{r.description}</p>
)}
</div>
<span className="shrink-0 text-slate-600 group-hover:text-slate-400 transition-colors text-lg"></span>
</div>
<div className="mt-4 flex items-center gap-4 text-xs text-slate-600">
<span>{formatBytes(r.size)}</span>
<span>{new Date(r.uploadedAt).toLocaleDateString()}</span>
</div>
</Link>
))}
</div>
)}
</div> </div>
); );
} }
+86
View File
@@ -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<Variant, { hidden: string; visible: string }> = {
"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<HTMLDivElement>(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 (
<div
ref={ref}
className={`${visible ? vis : hidden} ${className}`}
style={{
transition: `opacity ${duration}ms cubic-bezier(0.22,1,0.36,1) ${delay}ms, transform ${duration}ms cubic-bezier(0.22,1,0.36,1) ${delay}ms`,
willChange: "opacity, transform",
}}
>
{children}
</div>
);
}
+82
View File
@@ -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<HTMLSpanElement>(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 (
<span ref={ref}>
{prefix}
{display}
{suffix}
</span>
);
}
+40
View File
@@ -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 (
<button
onClick={handleLogout}
disabled={loading}
className="px-3 py-2 rounded-lg text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all disabled:opacity-50"
title="Sign out"
>
{loading ? (
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<span className="flex items-center gap-1.5">
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4" aria-hidden="true">
<path fillRule="evenodd" d="M3 4.25A2.25 2.25 0 015.25 2h5.5A2.25 2.25 0 0113 4.25v2a.75.75 0 01-1.5 0v-2a.75.75 0 00-.75-.75h-5.5a.75.75 0 00-.75.75v11.5c0 .414.336.75.75.75h5.5a.75.75 0 00.75-.75v-2a.75.75 0 011.5 0v2A2.25 2.25 0 0110.75 18h-5.5A2.25 2.25 0 013 15.75V4.25z" clipRule="evenodd" />
<path fillRule="evenodd" d="M6 10a.75.75 0 01.75-.75h9.546l-1.048-.943a.75.75 0 111.004-1.114l2.5 2.25a.75.75 0 010 1.114l-2.5 2.25a.75.75 0 11-1.004-1.114l1.048-.943H6.75A.75.75 0 016 10z" clipRule="evenodd" />
</svg>
<span className="hidden sm:inline">Sign out</span>
</span>
)}
</button>
)
}
+44
View File
@@ -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<HTMLDivElement>(null);
function onMouseMove(e: React.MouseEvent<HTMLDivElement>) {
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 (
<div
ref={ref}
className={`spotlight ${className}`}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
>
{children}
</div>
);
}
+115
View File
@@ -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<number[]>([]);
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 (
<div className="font-mono text-xs sm:text-sm leading-[1.85] select-none">
{doneLines.map((i) => {
const line = LINES[i];
const prompt = linePrompt(line);
return (
<div key={i} className={`flex ${lineColor(line)}`}>
{prompt && <span className="text-cyan-500 shrink-0 select-none">{prompt}</span>}
<span>{line.text}</span>
</div>
);
})}
{current && current.idx < LINES.length && (() => {
const line = LINES[current.idx];
const prompt = linePrompt(line);
const partial = line.text.slice(0, current.chars);
return (
<div className={`flex ${lineColor(line)}`}>
{prompt && <span className="text-cyan-500 shrink-0 select-none">{prompt}</span>}
<span>{partial}</span>
<span className="inline-block w-[7px] h-[0.9em] bg-cyan-400 ml-px animate-blink self-center" />
</div>
);
})()}
</div>
);
}
+42 -5
View File
@@ -1,18 +1,55 @@
"use client"; "use client";
import { useState } from "react";
export default function CopyLinkButton({ token }: { token: string }) { export default function CopyLinkButton({ token }: { token: string }) {
function copy() { const [copied, setCopied] = useState(false);
async function copy() {
const url = `${window.location.origin}/files/${token}`; const url = `${window.location.origin}/files/${token}`;
navigator.clipboard.writeText(url).then(() => { try {
alert("Link copied to clipboard!"); 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 ( return (
<button <button
onClick={copy} onClick={copy}
className="flex w-full items-center justify-center gap-2 rounded-xl border border-slate-700 py-3 text-sm font-semibold text-slate-300 hover:bg-slate-800 hover:text-white transition-colors" className={`flex w-full items-center justify-center gap-2.5 rounded-xl border py-3 text-sm font-semibold transition-all ${
copied
? "border-emerald-500/40 bg-emerald-500/8 text-emerald-400"
: "border-white/8 bg-white/3 text-slate-300 hover:bg-white/6 hover:border-white/16 hover:text-white"
}`}
> >
{copied ? (
<>
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
Link copied!
</>
) : (
<>
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
Copy share link Copy share link
</>
)}
</button> </button>
); );
} }
+99 -53
View File
@@ -9,6 +9,15 @@ function formatBytes(n: number) {
return `${(n / 1024 / 1024).toFixed(2)} MB`; return `${(n / 1024 / 1024).toFixed(2)} MB`;
} }
function MetaRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-6 py-3 border-b border-white/5 last:border-0">
<span className="text-sm text-slate-500 shrink-0">{label}</span>
<span className="text-sm text-slate-200 text-right font-mono">{value}</span>
</div>
);
}
export default async function FileDetailPage({ export default async function FileDetailPage({
params, params,
}: { }: {
@@ -20,98 +29,135 @@ export default async function FileDetailPage({
const isBackerup = record.type === "backerup"; const isBackerup = record.type === "backerup";
const downloadUrl = `/api/files/${token}/download`; const downloadUrl = `/api/files/${token}/download`;
const title = record.displayName ?? record.containerName ?? record.originalName;
return ( return (
<div className="mx-auto max-w-2xl px-6 py-16"> <div className="mx-auto max-w-2xl px-6 py-16">
<Link href="/browse" className="text-xs text-slate-500 hover:text-slate-300 transition-colors"> {/* Back */}
Back to browse <Link
href="/browse"
className="inline-flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition-colors mb-8"
>
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path d="M19 12H5M12 5l-7 7 7 7" />
</svg>
Back to browse
</Link> </Link>
<div className="mt-6"> {/* Header card */}
<div className="flex items-center gap-3 flex-wrap"> <div className="rounded-2xl border border-white/6 glass p-7 mb-6">
{/* Badges */}
<div className="flex flex-wrap items-center gap-2 mb-4">
{isBackerup ? ( {isBackerup ? (
<span className="rounded-full bg-cyan-500/15 px-3 py-1 text-xs font-semibold text-cyan-400"> <span className="rounded-full bg-cyan-500/12 border border-cyan-500/25 px-3 py-1 text-xs font-semibold text-cyan-400">
.backerup .backerup archive
</span> </span>
) : ( ) : (
<span className="rounded-full bg-violet-500/15 px-3 py-1 text-xs font-semibold text-violet-400"> <span className="rounded-full bg-violet-500/12 border border-violet-500/25 px-3 py-1 text-xs font-semibold text-violet-400">
.json config .json config
</span> </span>
)} )}
<span className="font-mono text-sm text-slate-500">{record.originalName}</span>
</div>
{record.group && ( {record.group && (
<span className="mt-3 inline-block rounded-full bg-slate-700/60 px-3 py-0.5 text-xs font-medium text-slate-400"> <span className="rounded-full bg-white/5 border border-white/10 px-3 py-1 text-xs font-medium text-slate-400">
{record.group} {record.group}
</span> </span>
)} )}
<h1 className="mt-4 text-3xl font-bold text-white"> <span className="font-mono text-xs text-slate-600 ml-auto">{record.originalName}</span>
{record.displayName ?? record.containerName ?? record.originalName} </div>
{/* Title */}
<h1 className="text-3xl font-extrabold tracking-tight text-white leading-snug">
{title}
</h1> </h1>
{/* Image */}
{record.image && ( {record.image && (
<p className="mt-1 text-slate-400">{record.image}</p> <p className="mt-2 font-mono text-sm text-slate-400">{record.image}</p>
)} )}
{/* Description */}
{record.description && ( {record.description && (
<p className="mt-4 text-sm leading-relaxed text-slate-300">{record.description}</p> <p className="mt-4 text-slate-400 leading-relaxed border-t border-white/5 pt-4">
{record.description}
</p>
)} )}
</div> </div>
{/* Metadata grid */} {/* Metadata */}
<div className="mt-8 rounded-2xl border border-slate-800 bg-slate-900/60 p-6 space-y-3"> <div className="rounded-2xl border border-white/6 glass px-6 py-2 mb-6">
<div className="flex items-center justify-between"> <MetaRow label="File size" value={formatBytes(record.size)} />
<span className="text-xs text-slate-500">File size</span> <MetaRow
<span className="text-sm font-mono text-slate-300">{formatBytes(record.size)}</span> label="Uploaded"
value={new Date(record.uploadedAt).toLocaleString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
/>
{record.group && <MetaRow label="Group" value={record.group} />}
{record.containerName && <MetaRow label="Container name" value={record.containerName} />}
{record.image && <MetaRow label="Image" value={record.image} />}
<MetaRow label="Share token" value={token} />
</div> </div>
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">Uploaded</span> {/* Docker restore snippet for configs */}
<span className="text-sm text-slate-300"> {!isBackerup && record.image && (
{new Date(record.uploadedAt).toLocaleString()} <div className="rounded-2xl border border-white/6 glass overflow-hidden mb-6">
</span> <div className="flex items-center gap-2 border-b border-white/6 px-5 py-3 bg-white/2">
<span className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500/70" />
<span className="ml-2 text-xs font-mono text-slate-500">quick restore</span>
</div> </div>
{record.group && ( <pre className="overflow-x-auto p-5 text-xs font-mono leading-6 text-slate-300">
<div className="flex items-center justify-between"> <span className="text-slate-600 select-none"># Pull the image used by this config</span>{"\n"}
<span className="text-xs text-slate-500">Group</span> <span className="text-cyan-500 select-none">$ </span>
<span className="text-sm font-mono text-slate-300">{record.group}</span> <span>docker pull {record.image}</span>{"\n\n"}
<span className="text-slate-600 select-none"># Download & import this config into Backerup to restore</span>{"\n"}
<span className="text-cyan-500 select-none">$ </span>
<span>curl -O {`{{BACKERUP_URL}}`}/api/files/{token}/download</span>
</pre>
</div> </div>
)} )}
{record.containerName && (
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">Container name</span>
<span className="text-sm font-mono text-slate-300">{record.containerName}</span>
</div>
)}
{record.image && (
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">Image</span>
<span className="text-sm font-mono text-slate-300">{record.image}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">Share token</span>
<span className="text-sm font-mono text-slate-400">{token}</span>
</div>
</div>
{/* Actions */} {/* Actions */}
<div className="mt-6 space-y-3"> <div className="space-y-3">
<a <a
href={downloadUrl} href={downloadUrl}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-cyan-500 py-3 text-sm font-bold text-slate-900 hover:bg-cyan-400 transition-colors" className="flex w-full items-center justify-center gap-2.5 rounded-xl py-3.5 text-sm font-bold text-slate-900 transition-all hover:brightness-110 hover:scale-[1.01] active:scale-[0.99]"
style={{ background: "linear-gradient(135deg, #22d3ee, #06b6d4)" }}
download={record.originalName} download={record.originalName}
> >
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Download {record.originalName} Download {record.originalName}
</a> </a>
{/* Copy link */}
<CopyLinkButton token={token} /> <CopyLinkButton token={token} />
<Link
href="/browse"
className="flex w-full items-center justify-center gap-2 rounded-xl border border-white/8 bg-white/3 py-3 text-sm font-medium text-slate-400 hover:text-white hover:bg-white/6 transition-all"
>
Browse more configs
</Link>
</div> </div>
{/* Warning for .backerup */}
{isBackerup && ( {isBackerup && (
<div className="mt-8 rounded-xl border border-amber-500/20 bg-amber-500/5 p-4 text-xs text-amber-300"> <div className="mt-6 flex gap-3 rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
<strong>Note:</strong> This is a full .backerup archive. It may contain volume data <span className="text-amber-400 shrink-0 mt-0.5">
in addition to the container config. Import it into Backerup to restore <svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
or clone the container. <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</span>
<p className="text-xs text-amber-300 leading-relaxed">
<strong className="font-semibold">Note:</strong> 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.
</p>
</div> </div>
)} )}
</div> </div>
+275 -8
View File
@@ -1,19 +1,286 @@
@import "tailwindcss"; @import "tailwindcss";
:root {
--background: #0b0f1a;
--foreground: #e2e8f0;
}
@theme inline { @theme inline {
--color-background: var(--background); --color-background: #060810;
--color-foreground: var(--foreground); --color-foreground: #e2e8f0;
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --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 { body {
background: var(--background); background: var(--background);
color: var(--foreground); 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);
} }
+159 -21
View File
@@ -1,6 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import Link from "next/link"; import Link from "next/link";
import { LINKS } from "@/lib/links";
import { getSession } from "@/lib/session";
import LogoutButton from "./components/LogoutButton";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -19,32 +22,115 @@ export const metadata: Metadata = {
"Simple, reliable Docker container backups. Share and discover container configs and backup snapshots.", "Simple, reliable Docker container backups. Share and discover container configs and backup snapshots.",
}; };
export default function RootLayout({ function GitHubIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className ?? "w-4 h-4"} fill="currentColor" aria-hidden="true">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
);
}
function DockerIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className ?? "w-4 h-4"} fill="currentColor" aria-hidden="true">
<path d="M13.983 11.078h2.119a.186.186 0 0 0 .186-.185V9.006a.186.186 0 0 0-.186-.186h-2.119a.185.185 0 0 0-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 0 0 .186-.186V3.574a.186.186 0 0 0-.186-.185h-2.118a.185.185 0 0 0-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 0 0 .186-.186V6.29a.186.186 0 0 0-.186-.185h-2.118a.185.185 0 0 0-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 0 0 .184-.186V6.29a.185.185 0 0 0-.185-.185H8.1a.185.185 0 0 0-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 0 0 .185-.186V6.29a.185.185 0 0 0-.185-.185H5.136a.186.186 0 0 0-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 0 0 .186-.185V9.006a.186.186 0 0 0-.186-.186h-2.118a.185.185 0 0 0-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 0 0 .184-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.185.185 0 0 0-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 0 0 .185-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.186.186 0 0 0-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.186.186 0 0 0 .184-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.185.185 0 0 0-.185.186v1.887c0 .102.083.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 0 0-.75.748 11.376 11.376 0 0 0 .692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 0 0 3.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z" />
</svg>
);
}
export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const session = await getSession();
return ( return (
<html <html
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col bg-[#0b0f1a] text-slate-200"> <body className="min-h-full flex flex-col" style={{ background: "#060810", color: "#e2e8f0" }}>
{/* Nav */} {/* ── Global ambient background ── */}
<header className="sticky top-0 z-50 border-b border-slate-800 bg-[#0b0f1a]/90 backdrop-blur"> <div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<div
className="absolute -top-96 -left-64 w-[900px] h-[900px] rounded-full animate-pulse-slow"
style={{ background: "radial-gradient(circle, rgba(6,182,212,0.07) 0%, transparent 65%)" }}
/>
<div
className="absolute top-1/3 -right-64 w-[700px] h-[700px] rounded-full animate-pulse-slow"
style={{ background: "radial-gradient(circle, rgba(139,92,246,0.07) 0%, transparent 65%)", animationDelay: "2.5s" }}
/>
<div
className="absolute -bottom-48 left-1/4 w-[600px] h-[600px] rounded-full animate-pulse-slow"
style={{ background: "radial-gradient(circle, rgba(6,182,212,0.04) 0%, transparent 65%)", animationDelay: "4s" }}
/>
<div className="absolute inset-0 grid-bg" />
</div>
{/* ── Nav ── */}
<header className="sticky top-0 z-50 glass-heavy">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4"> <div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<Link href="/" className="flex items-center gap-2 font-bold text-lg text-white"> <Link href="/" className="flex items-center gap-2.5 group">
<span className="rounded bg-cyan-500 px-1.5 py-0.5 text-xs font-mono text-slate-900"> <div
.backerup className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "linear-gradient(135deg, #22d3ee, #0891b2)" }}
>
<DockerIcon className="w-4 h-4 text-slate-900" />
</div>
<span className="text-base font-bold text-white group-hover:text-cyan-300 transition-colors tracking-tight">
Backerup
</span> </span>
<span>Backerup</span>
</Link> </Link>
<nav className="flex items-center gap-6 text-sm text-slate-400">
<Link href="/browse" className="hover:text-white transition-colors">Browse</Link> <nav className="flex items-center gap-1 text-sm font-medium">
<Link href="/share" className="hover:text-white transition-colors">Share</Link> <Link
href="/browse"
className="nav-link px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
Browse
</Link>
<Link <Link
href="/share" href="/share"
className="rounded-lg bg-cyan-500 px-4 py-1.5 text-sm font-semibold text-slate-900 hover:bg-cyan-400 transition-colors" className="nav-link px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
Share
</Link>
<a
href={LINKS.github}
target="_blank"
rel="noopener noreferrer"
className="nav-link px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all flex items-center gap-1.5"
>
<GitHubIcon />
<span className="hidden sm:inline">GitHub</span>
</a>
{session?.role === "admin" && (
<Link
href="/admin"
className="nav-link px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
Admin
</Link>
)}
{session ? (
<div className="ml-2 flex items-center gap-2">
<span className="hidden sm:block text-xs text-slate-500 px-2">
{session.username}
</span>
<LogoutButton />
</div>
) : (
<Link
href="/login"
className="ml-2 px-4 py-2 rounded-lg font-semibold text-sm text-slate-300 border border-white/10 hover:border-white/20 hover:text-white transition-all"
>
Sign in
</Link>
)}
<Link
href="/share"
className="ml-1 px-4 py-2 rounded-lg font-semibold text-slate-900 transition-all hover:brightness-110 active:scale-95"
style={{ background: "linear-gradient(135deg, #22d3ee, #06b6d4)" }}
> >
Upload Upload
</Link> </Link>
@@ -52,20 +138,72 @@ export default function RootLayout({
</div> </div>
</header> </header>
{/* Page content */} {/* ── Page content ── */}
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>
{/* Footer */} {/* ── Footer ── */}
<footer className="border-t border-slate-800 py-8 text-center text-xs text-slate-600"> <footer className="mt-24 border-t border-white/5">
<p> <div className="mx-auto max-w-6xl px-6 py-16">
Backerup &mdash; open-source Docker backup.{" "} <div className="grid gap-12 sm:grid-cols-2 lg:grid-cols-4">
<a {/* Brand */}
href="https://github.com" <div className="lg:col-span-2">
className="text-slate-500 hover:text-slate-300 transition-colors" <div className="flex items-center gap-2.5 mb-5">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{ background: "linear-gradient(135deg, #22d3ee, #0891b2)" }}
> >
<DockerIcon className="w-4 h-4 text-slate-900" />
</div>
<span className="font-bold text-white text-lg tracking-tight">Backerup</span>
</div>
<p className="text-sm text-slate-500 max-w-xs leading-relaxed">
Open-source Docker container backup tool. Self-hosted, portable, and reliable.
Archive volumes, restore configs, and share setups with your team.
</p>
<div className="mt-6 flex gap-3">
<a
href={LINKS.github}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-white/10 bg-white/3 text-xs text-slate-400 hover:text-white hover:border-white/20 transition-all"
>
<GitHubIcon />
GitHub GitHub
</a> </a>
</p> </div>
</div>
{/* Product */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4">Product</h3>
<ul className="space-y-3 text-sm text-slate-500">
<li><Link href="/browse" className="hover:text-cyan-400 transition-colors">Browse configs</Link></li>
<li><Link href="/share" className="hover:text-cyan-400 transition-colors">Share a backup</Link></li>
<li><a href={LINKS.docs} className="hover:text-cyan-400 transition-colors">Documentation</a></li>
<li><a href={LINKS.githubReleases} className="hover:text-cyan-400 transition-colors">Changelog</a></li>
</ul>
</div>
{/* Community */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4">Community</h3>
<ul className="space-y-3 text-sm text-slate-500">
<li><a href={LINKS.github} className="hover:text-cyan-400 transition-colors">GitHub</a></li>
<li><a href={LINKS.githubIssues} className="hover:text-cyan-400 transition-colors">Issues</a></li>
<li><a href={LINKS.githubDiscussions} className="hover:text-cyan-400 transition-colors">Discussions</a></li>
<li><a href={LINKS.githubContributing} className="hover:text-cyan-400 transition-colors">Contributing</a></li>
</ul>
</div>
</div>
<div className="mt-12 pt-8 border-t border-white/5 flex flex-wrap items-center justify-between gap-4 text-xs text-slate-600">
<p>© {new Date().getFullYear()} Backerup Open source under the MIT License.</p>
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span>All systems operational</span>
</div>
</div>
</div>
</footer> </footer>
</body> </body>
</html> </html>
+142
View File
@@ -0,0 +1,142 @@
'use client'
import { useState, useRef } from 'react'
import { useRouter } from 'next/navigation'
interface Props {
from: string
}
export default function LoginForm({ from }: Props) {
const router = useRouter()
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const usernameRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
const username = usernameRef.current?.value.trim() ?? ''
const password = passwordRef.current?.value ?? ''
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
const data = (await res.json()) as { error?: string }
if (!res.ok) {
setError(data.error ?? 'Login failed')
setLoading(false)
return
}
// Navigate to intended destination or browse
router.push(from || '/browse')
router.refresh()
} catch {
setError('Network error — please try again')
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
{error && (
<div
className="flex items-center gap-2.5 px-4 py-3 rounded-xl text-sm border"
style={{
background: 'rgba(239,68,68,0.08)',
borderColor: 'rgba(239,68,68,0.3)',
color: '#fca5a5',
}}
role="alert"
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0" aria-hidden="true">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
clipRule="evenodd"
/>
</svg>
{error}
</div>
)}
<div className="space-y-1.5">
<label htmlFor="username" className="block text-sm font-medium text-slate-300">
Username
</label>
<input
id="username"
ref={usernameRef}
type="text"
autoComplete="username"
required
autoFocus
className="w-full rounded-xl px-4 py-3 text-sm outline-none transition-all"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.1)',
color: '#e2e8f0',
}}
onFocus={(e) =>
(e.currentTarget.style.borderColor = 'rgba(34,211,238,0.5)')
}
onBlur={(e) =>
(e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)')
}
placeholder="Enter your username"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="password" className="block text-sm font-medium text-slate-300">
Password
</label>
<input
id="password"
ref={passwordRef}
type="password"
autoComplete="current-password"
required
className="w-full rounded-xl px-4 py-3 text-sm outline-none transition-all"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.1)',
color: '#e2e8f0',
}}
onFocus={(e) =>
(e.currentTarget.style.borderColor = 'rgba(34,211,238,0.5)')
}
onBlur={(e) =>
(e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)')
}
placeholder="Enter your password"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 rounded-xl font-semibold text-sm text-slate-900 transition-all hover:brightness-110 active:scale-[0.98] disabled:opacity-60 disabled:cursor-not-allowed"
style={{ background: 'linear-gradient(135deg, #22d3ee, #06b6d4)' }}
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Signing in
</span>
) : (
'Sign in'
)}
</button>
</form>
)
}
+53
View File
@@ -0,0 +1,53 @@
import type { Metadata } from 'next'
import LoginForm from './LoginForm'
export const metadata: Metadata = {
title: 'Sign In — Backerup',
}
interface Props {
searchParams: Promise<{ from?: string }>
}
export default async function LoginPage({ searchParams }: Props) {
const { from = '/browse' } = await searchParams
// Only allow relative paths to prevent open redirects
const safePath = from.startsWith('/') && !from.startsWith('//') ? from : '/browse'
return (
<div className="flex min-h-[calc(100vh-72px)] items-center justify-center px-4 py-16">
<div className="w-full max-w-md">
{/* Logo + heading */}
<div className="text-center mb-10">
<div
className="mx-auto mb-5 w-14 h-14 rounded-2xl flex items-center justify-center"
style={{ background: 'linear-gradient(135deg, #22d3ee, #0891b2)' }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7 text-slate-900" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-white tracking-tight">Welcome back</h1>
<p className="mt-1.5 text-sm text-slate-400">Sign in to your Backerup account</p>
</div>
{/* Card */}
<div
className="rounded-2xl p-8"
style={{
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)',
backdropFilter: 'blur(16px)',
}}
>
<LoginForm from={safePath} />
</div>
<p className="mt-6 text-center text-xs text-slate-500">
Access is managed by your administrator.
</p>
</div>
</div>
)
}
+524 -89
View File
@@ -1,170 +1,605 @@
import Link from "next/link"; import Link from "next/link";
import TerminalTyper from "./components/TerminalTyper";
import AnimateIn from "./components/AnimateIn";
import CountUp from "./components/CountUp";
import SpotlightCard from "./components/SpotlightCard";
import { LINKS } from "@/lib/links";
/* ── SVG Icons ─────────────────────────────────────────────── */
const CubeIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
</svg>
);
const FileIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
);
const RefreshIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
);
const ClockIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
const ShareIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" />
</svg>
);
const ShieldIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
);
const DownloadIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-5 h-5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
);
const CogIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-5 h-5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
const ArchiveIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-5 h-5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
);
const PlayIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-5 h-5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
);
const GitHubIcon = () => (
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="currentColor" aria-hidden="true">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
);
/* ── Data ───────────────────────────────────────────────────── */
const features = [ const features = [
{ {
icon: "📦", icon: <CubeIcon />,
gradient: "from-cyan-500/15 to-blue-600/15",
border: "border-cyan-500/20",
iconBg: "bg-cyan-500/10",
iconColor: "text-cyan-400",
title: "Full container backups", title: "Full container backups",
body: "Archive volumes, bind mounts, and Docker inspect metadata into a single .backerup bundle — everything needed to fully restore or clone a container.", body: "Archive volumes, bind mounts, and Docker inspect metadata into a single .backerup bundle — everything needed to fully restore or clone a container.",
}, },
{ {
icon: "🗂", icon: <FileIcon />,
gradient: "from-violet-500/15 to-purple-600/15",
border: "border-violet-500/20",
iconBg: "bg-violet-500/10",
iconColor: "text-violet-400",
title: "Config-only export", title: "Config-only export",
body: "Export a lightweight .config.json (raw docker inspect output) to recreate a container on any host without moving volume data.", body: "Export a lightweight config.json (raw docker inspect output) to recreate a container on any host — no volume data needed.",
}, },
{ {
icon: "🔄", icon: <RefreshIcon />,
gradient: "from-emerald-500/15 to-teal-600/15",
border: "border-emerald-500/20",
iconBg: "bg-emerald-500/10",
iconColor: "text-emerald-400",
title: "One-click restore", title: "One-click restore",
body: "Restore a container's volumes and config in-place, rebuild its config without touching data, or spin up a brand-new container from any backup.", body: "Restore volumes in-place, rebuild config without touching data, or spin up a brand-new container from any backup — your choice.",
}, },
{ {
icon: "📅", icon: <ClockIcon />,
gradient: "from-amber-500/15 to-orange-600/15",
border: "border-amber-500/20",
iconBg: "bg-amber-500/10",
iconColor: "text-amber-400",
title: "Scheduled backups", title: "Scheduled backups",
body: "Define cron-based backup jobs per container with configurable retention strategies: keep latest N, keep every N-th, or purge older than N days.", body: "Define cron-based backup jobs per container with configurable retention: keep latest N, keep every N-th, or purge older than N days.",
}, },
{ {
icon: "🌐", icon: <ShareIcon />,
gradient: "from-pink-500/15 to-rose-600/15",
border: "border-pink-500/20",
iconBg: "bg-pink-500/10",
iconColor: "text-pink-400",
title: "Community sharing", title: "Community sharing",
body: "Publish your container configs so others can spin up the same setup in seconds. Browse configs shared by the community right here.", body: "Publish your container configs so others can spin up the same setup in seconds. Browse configs shared by the community right here.",
}, },
{ {
icon: "🔒", icon: <ShieldIcon />,
gradient: "from-slate-400/10 to-slate-500/10",
border: "border-slate-600/30",
iconBg: "bg-slate-500/10",
iconColor: "text-slate-300",
title: "Self-hosted & private", title: "Self-hosted & private",
body: "Runs entirely inside your Docker environment. No cloud dependencies, no data leaves your server unless you choose to share.", body: "Runs entirely inside your Docker environment. No cloud dependencies, no data leaves your server unless you choose to share.",
}, },
]; ];
const steps = [ const steps = [
{ n: "1", title: "Install", body: "Run Backerup as a Docker container with access to your Docker socket." }, {
{ n: "2", title: "Configure", body: "Add containers, choose a backup strategy, and set a cron schedule." }, n: "01",
{ n: "3", title: "Backup", body: "Backerup archives your volumes and metadata into a portable .backerup file." }, icon: <DownloadIcon />,
{ n: "4", title: "Restore", body: "One click to restore in-place, rebuild config, or create a new container." }, color: "bg-cyan-500/20 text-cyan-400",
title: "Install",
body: "Run Backerup as a Docker container with access to your Docker socket. Up and running in under 30 seconds.",
},
{
n: "02",
icon: <CogIcon />,
color: "bg-violet-500/20 text-violet-400",
title: "Configure",
body: "Add containers, choose a backup strategy (full archive or config-only), and set a cron schedule.",
},
{
n: "03",
icon: <ArchiveIcon />,
color: "bg-amber-500/20 text-amber-400",
title: "Backup",
body: "Backerup archives your volumes and metadata into a portable .backerup file on your schedule.",
},
{
n: "04",
icon: <PlayIcon />,
color: "bg-emerald-500/20 text-emerald-400",
title: "Restore",
body: "One click to restore in-place, rebuild config, or spin up a fresh container from any backup.",
},
]; ];
const pills = [
{ label: "MIT Licensed", color: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20" },
{ label: "Zero cloud deps", color: "text-cyan-400 bg-cyan-500/10 border-cyan-500/20" },
{ label: "Self-hosted", color: "text-violet-400 bg-violet-500/10 border-violet-500/20" },
{ label: "Docker native", color: "text-blue-400 bg-blue-500/10 border-blue-500/20" },
];
const stats = [
{ num: 100, suffix: "%", label: "Open source", sub: "MIT licensed" },
{ num: 500, suffix: " MB", label: "Max file size", sub: "per upload" },
{ num: 6, suffix: "+", label: "Backup strategies", sub: "fully configurable" },
{ num: 0, suffix: "", label: "Cloud dependencies", sub: "runs on your infra" },
];
/* ── Page ───────────────────────────────────────────────────── */
export default function LandingPage() { export default function LandingPage() {
return ( return (
<> <>
{/* Hero */} {/* ═══ Hero ═══════════════════════════════════════════════ */}
<section className="relative overflow-hidden px-6 py-28 text-center"> <section className="relative overflow-hidden px-6 pt-28 pb-20">
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> {/* ── Animated aurora blobs ── */}
<div className="h-[500px] w-[900px] rounded-full bg-cyan-500/10 blur-3xl" /> <div className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute -top-[35%] -left-[18%] w-[750px] h-[750px] rounded-full"
style={{
background: "radial-gradient(circle, rgba(6,182,212,0.13) 0%, transparent 60%)",
filter: "blur(55px)",
animation: "aurora1 18s ease-in-out infinite alternate",
}}
/>
<div
className="absolute -top-[25%] right-[-5%] w-[650px] h-[650px] rounded-full"
style={{
background: "radial-gradient(circle, rgba(139,92,246,0.11) 0%, transparent 60%)",
filter: "blur(55px)",
animation: "aurora2 23s ease-in-out infinite alternate-reverse",
}}
/>
<div
className="absolute top-[28%] left-[22%] w-[520px] h-[520px] rounded-full"
style={{
background: "radial-gradient(circle, rgba(59,130,246,0.09) 0%, transparent 60%)",
filter: "blur(55px)",
animation: "aurora3 15s ease-in-out infinite alternate",
}}
/>
</div> </div>
<div className="relative mx-auto max-w-3xl">
<span className="inline-block rounded-full border border-cyan-500/30 bg-cyan-500/10 px-4 py-1 text-xs font-semibold uppercase tracking-widest text-cyan-400"> {/* ── Hero text — above the fold, animate on page load ── */}
Open Source · Self-Hosted <div className="relative mx-auto max-w-4xl text-center">
{/* Pills */}
<div
className="mb-8 flex flex-wrap items-center justify-center gap-2"
style={{ animation: "slideUp 0.7s cubic-bezier(0.22,1,0.36,1) 80ms both" }}
>
{pills.map((p) => (
<span
key={p.label}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-semibold ${p.color}`}
>
<span className="w-1 h-1 rounded-full bg-current opacity-70" />
{p.label}
</span> </span>
<h1 className="mt-6 text-5xl font-bold leading-tight tracking-tight text-white sm:text-6xl"> ))}
Docker backups,{" "} </div>
<span className="text-cyan-400">done right.</span>
{/* Headline */}
<h1
className="text-5xl font-extrabold leading-[1.1] tracking-tight text-white sm:text-7xl"
style={{ animation: "slideUp 0.75s cubic-bezier(0.22,1,0.36,1) 220ms both" }}
>
Docker backups,
<br />
<span className="gradient-text">done right.</span>
</h1> </h1>
<p className="mt-6 text-lg text-slate-400">
Backerup bundles your container volumes, bind mounts, and config into {/* Subtitle */}
a single portable file. Restore in seconds, share configs with your <p
team, or migrate containers between hosts. className="mx-auto mt-7 max-w-2xl text-lg leading-relaxed text-slate-400"
style={{ animation: "slideUp 0.75s cubic-bezier(0.22,1,0.36,1) 380ms both" }}
>
Backerup bundles your container volumes, bind mounts, and config into a single portable
file. Restore in seconds, share configs with your team, or migrate containers between
hosts all from a self-hosted UI.
</p> </p>
<div className="mt-10 flex flex-wrap justify-center gap-4">
{/* CTA buttons */}
<div
className="mt-10 flex flex-wrap justify-center gap-4"
style={{ animation: "slideUp 0.75s cubic-bezier(0.22,1,0.36,1) 520ms both" }}
>
<Link <Link
href="/share" href="/share"
className="rounded-xl bg-cyan-500 px-8 py-3 text-sm font-bold text-slate-900 hover:bg-cyan-400 transition-colors" className="group flex items-center gap-2 rounded-xl px-7 py-3.5 text-sm font-bold text-slate-900 transition-all hover:brightness-110 hover:scale-105 active:scale-95 shadow-lg animate-border-glow"
style={{ background: "linear-gradient(135deg, #22d3ee, #06b6d4)" }}
> >
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
Share a config Share a config
</Link> </Link>
<Link <Link
href="/browse" href="/browse"
className="rounded-xl border border-slate-700 bg-slate-800/50 px-8 py-3 text-sm font-semibold text-slate-200 hover:bg-slate-700 transition-colors" className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-7 py-3.5 text-sm font-semibold text-slate-200 transition-all hover:bg-white/10 hover:border-white/20 hover:scale-105 active:scale-95"
> >
Browse configs Browse configs
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</Link> </Link>
<a
href={LINKS.github}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-xl border border-white/8 bg-white/3 px-7 py-3.5 text-sm font-semibold text-slate-300 transition-all hover:bg-white/8 hover:text-white hover:scale-105"
>
<GitHubIcon />
GitHub
</a>
</div> </div>
</div> </div>
{/* Terminal preview */} {/* ── Terminal card ── */}
<div className="relative mx-auto mt-16 max-w-2xl rounded-2xl border border-slate-700 bg-slate-900 text-left shadow-2xl"> <div
<div className="flex items-center gap-2 border-b border-slate-700 px-4 py-3"> className="relative mx-auto mt-20 max-w-2xl"
style={{ animation: "slideUp 0.8s cubic-bezier(0.22,1,0.36,1) 680ms both" }}
>
{/* Glow behind terminal */}
<div
className="absolute inset-0 rounded-2xl blur-xl opacity-40"
style={{ background: "radial-gradient(ellipse, rgba(6,182,212,0.2) 0%, transparent 70%)" }}
/>
<div className="relative rounded-2xl border border-white/8 glass overflow-hidden shadow-2xl terminal-scan">
<div className="flex items-center gap-2 border-b border-white/6 px-5 py-3.5 bg-white/2">
<span className="h-3 w-3 rounded-full bg-red-500/70" /> <span className="h-3 w-3 rounded-full bg-red-500/70" />
<span className="h-3 w-3 rounded-full bg-yellow-500/70" /> <span className="h-3 w-3 rounded-full bg-yellow-500/70" />
<span className="h-3 w-3 rounded-full bg-green-500/70" /> <span className="h-3 w-3 rounded-full bg-emerald-500/70" />
<span className="ml-4 text-xs text-slate-500 font-mono">backerup api</span> <span className="ml-3 text-xs text-slate-500 font-mono tracking-wide">backerup · quick start</span>
<div className="ml-auto flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-xs text-slate-600 font-mono">live</span>
</div>
</div>
<div className="p-6 min-h-[260px]">
<TerminalTyper />
</div>
</div> </div>
<pre className="overflow-x-auto p-6 text-xs leading-6 font-mono text-slate-300">
{`
# Download the App
docker pull ghcr.io/evan-buss/backerup:latest
`}
</pre>
</div> </div>
</section> </section>
{/* Features */} {/* ═══ Stats bar ══════════════════════════════════════════ */}
<section className="px-6 py-20"> <section className="px-6 py-12">
<div className="mx-auto max-w-4xl">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats.map((s, i) => (
<AnimateIn key={s.label} variant="scale-up" delay={i * 90}>
<div className="rounded-2xl border border-white/5 glass p-5 text-center">
<div className="text-2xl font-extrabold text-white tracking-tight">
<CountUp to={s.num} suffix={s.suffix} duration={1800} />
</div>
<div className="mt-1 text-sm font-medium text-slate-300">{s.label}</div>
<div className="mt-0.5 text-xs text-slate-600">{s.sub}</div>
</div>
</AnimateIn>
))}
</div>
</div>
</section>
{/* ═══ Features ═══════════════════════════════════════════ */}
<section className="px-6 py-24">
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl">
<h2 className="text-center text-3xl font-bold text-white">Everything you need</h2> <AnimateIn variant="fade-up">
<p className="mt-3 text-center text-slate-400"> <div className="mx-auto max-w-2xl text-center">
A complete backup lifecycle all from a single self-hosted UI. <span className="inline-block rounded-full border border-cyan-500/30 bg-cyan-500/10 px-4 py-1 text-xs font-semibold uppercase tracking-widest text-cyan-400">
</p> Features
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{features.map((f) => (
<div
key={f.title}
className="rounded-2xl border border-slate-800 bg-slate-900/60 p-6 transition hover:border-slate-700"
>
<div className="text-3xl">{f.icon}</div>
<h3 className="mt-4 font-semibold text-white">{f.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-slate-400">{f.body}</p>
</div>
))}
</div>
</div>
</section>
{/* How it works */}
<section className="bg-slate-900/40 px-6 py-20">
<div className="mx-auto max-w-4xl">
<h2 className="text-center text-3xl font-bold text-white">How it works</h2>
<div className="mt-14 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
{steps.map((s) => (
<div key={s.n} className="flex flex-col items-start gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-cyan-500/15 text-lg font-bold text-cyan-400">
{s.n}
</span> </span>
<h3 className="font-semibold text-white">{s.title}</h3> <h2 className="mt-5 text-4xl font-bold tracking-tight text-white">
<p className="text-sm text-slate-400">{s.body}</p> Everything you need
</h2>
<p className="mt-4 text-slate-400 leading-relaxed">
A complete Docker backup lifecycle from scheduled snapshots to one-click restores,
all from a single self-hosted UI.
</p>
</div> </div>
</AnimateIn>
<div className="mt-16 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{features.map((f, i) => (
<AnimateIn key={f.title} variant="fade-up" delay={i * 65}>
<SpotlightCard
className={`rounded-2xl border ${f.border} bg-gradient-to-br ${f.gradient} p-6 h-full card-hover`}
>
<div className={`inline-flex items-center justify-center w-11 h-11 rounded-xl ${f.iconBg} ${f.iconColor} mb-4`}>
{f.icon}
</div>
<h3 className="font-semibold text-white text-lg leading-snug">{f.title}</h3>
<p className="mt-2.5 text-sm leading-relaxed text-slate-400">{f.body}</p>
</SpotlightCard>
</AnimateIn>
))} ))}
</div> </div>
</div> </div>
</section> </section>
{/* Format callout */} {/* ═══ How it works ════════════════════════════════════════ */}
<section className="px-6 py-20"> <section className="px-6 py-24">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-5xl">
<div className="rounded-2xl border border-cyan-500/20 bg-cyan-500/5 p-10 text-center"> <AnimateIn variant="fade-up">
<h2 className="text-2xl font-bold text-white"> <div className="mx-auto max-w-2xl text-center mb-16">
The <span className="font-mono text-cyan-400">.backerup</span> format <span className="inline-block rounded-full border border-violet-500/30 bg-violet-500/10 px-4 py-1 text-xs font-semibold uppercase tracking-widest text-violet-400">
How it works
</span>
<h2 className="mt-5 text-4xl font-bold tracking-tight text-white">
Up and running in minutes
</h2> </h2>
<p className="mt-4 text-slate-400 max-w-xl mx-auto"> </div>
A single gzipped-tar file containing{" "} </AnimateIn>
<span className="font-mono text-slate-300">config.json</span>,{" "}
<span className="font-mono text-slate-300">manifest.json</span>, and{" "} <div className="relative grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
<span className="font-mono text-slate-300">volumes/*.tar.gz</span>. {/* Connector line between steps */}
Portable, transparent, and restorable without Backerup itself. <div
className="hidden lg:block absolute top-8 left-[12.5%] right-[12.5%] h-px"
style={{ background: "linear-gradient(90deg, rgba(6,182,212,0.5), rgba(139,92,246,0.5))" }}
/>
{steps.map((s, i) => (
<AnimateIn key={s.n} variant="fade-up" delay={i * 110}>
<div className="relative flex flex-col items-center text-center gap-4">
<div className="relative z-10 flex h-16 w-16 items-center justify-center rounded-2xl border border-white/10 glass">
<div className={`flex items-center justify-center w-10 h-10 rounded-xl ${s.color}`}>
{s.icon}
</div>
</div>
<div>
<div className="text-xs font-mono text-slate-600 mb-1">{s.n}</div>
<h3 className="font-bold text-white text-lg">{s.title}</h3>
<p className="mt-2 text-sm text-slate-400 leading-relaxed max-w-[200px] mx-auto">{s.body}</p>
</div>
</div>
</AnimateIn>
))}
</div>
</div>
</section>
{/* ═══ .backerup format ════════════════════════════════════ */}
<section className="px-6 py-24">
<div className="mx-auto max-w-5xl">
<AnimateIn variant="fade-up">
<div className="rounded-3xl border border-white/6 glass overflow-hidden">
<div className="grid lg:grid-cols-2">
{/* Left: text */}
<div className="p-10 lg:p-14 flex flex-col justify-center">
<span className="inline-block rounded-full border border-cyan-500/30 bg-cyan-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-widest text-cyan-400 mb-6 self-start">
File format
</span>
<h2 className="text-3xl font-bold text-white leading-snug">
The{" "}
<code className="font-mono text-cyan-400 text-2xl">.backerup</code>{" "}
format
</h2>
<p className="mt-4 text-slate-400 leading-relaxed">
A single gzipped-tar file containing everything needed to restore or clone a
container. Portable, transparent, and restorable{" "}
<span className="text-slate-300">without Backerup itself</span>.
</p> </p>
<div className="mt-8 flex flex-wrap justify-center gap-4"> <ul className="mt-6 space-y-3 text-sm text-slate-400">
{[
{ name: "config.json", desc: "Raw docker inspect output" },
{ name: "manifest.json", desc: "Backup metadata & checksums" },
{ name: "volumes/*.tar.gz", desc: "One archive per volume" },
].map((item) => (
<li key={item.name} className="flex items-start gap-3">
<span className="mt-0.5 text-cyan-500 shrink-0">
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</span>
<span>
<code className="font-mono text-slate-200 text-xs">{item.name}</code>
<span className="ml-2 text-slate-500"> {item.desc}</span>
</span>
</li>
))}
</ul>
<div className="mt-8 flex flex-wrap gap-3">
<Link <Link
href="/share" href="/share"
className="rounded-xl bg-cyan-500 px-8 py-3 text-sm font-bold text-slate-900 hover:bg-cyan-400 transition-colors" className="rounded-xl px-6 py-2.5 text-sm font-bold text-slate-900 transition-all hover:brightness-110"
style={{ background: "linear-gradient(135deg, #22d3ee, #06b6d4)" }}
> >
Upload a backup or config Upload a backup
</Link> </Link>
<Link <Link
href="/browse" href="/browse"
className="rounded-xl border border-slate-700 px-8 py-3 text-sm font-semibold text-slate-300 hover:text-white hover:border-slate-500 transition-colors" className="rounded-xl border border-white/10 bg-white/5 px-6 py-2.5 text-sm font-semibold text-slate-300 hover:bg-white/10 transition-all"
> >
Browse shared files Browse shared files
</Link> </Link>
</div> </div>
</div> </div>
{/* Right: file tree */}
<div className="border-t border-white/5 lg:border-t-0 lg:border-l border-white/5 p-10 lg:p-14 flex items-center">
<div className="font-mono text-sm w-full">
<div className="flex items-center gap-2.5 text-slate-300 mb-4">
<span className="text-amber-400">📦</span>
<span>my-postgres.backerup</span>
<span className="text-slate-600 text-xs ml-auto">142 MB</span>
</div>
<div className="ml-4 space-y-3 text-slate-500">
<div className="flex items-center gap-2.5">
<span className="text-slate-700"></span>
<span className="text-violet-400">📄</span>
<span>config.json</span>
<span className="text-slate-700 text-xs ml-auto">8 KB</span>
</div>
<div className="flex items-center gap-2.5">
<span className="text-slate-700"></span>
<span className="text-blue-400">📄</span>
<span>manifest.json</span>
<span className="text-slate-700 text-xs ml-auto">2 KB</span>
</div>
<div className="flex items-center gap-2.5">
<span className="text-slate-700"></span>
<span className="text-amber-400">📁</span>
<span className="text-slate-300">volumes/</span>
</div>
<div className="ml-6 space-y-3">
<div className="flex items-center gap-2.5">
<span className="text-slate-700"></span>
<span className="text-cyan-400">🗜</span>
<span>postgres-data.tar.gz</span>
<span className="text-slate-700 text-xs ml-auto">139 MB</span>
</div>
<div className="flex items-center gap-2.5">
<span className="text-slate-700"></span>
<span className="text-cyan-400">🗜</span>
<span>postgres-config.tar.gz</span>
<span className="text-slate-700 text-xs ml-auto">512 B</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</AnimateIn>
</div>
</section>
{/* ═══ Quick start ════════════════════════════════════════ */}
<section className="px-6 py-20">
<AnimateIn variant="fade-up">
<div className="mx-auto max-w-4xl text-center">
<span className="inline-block rounded-full border border-amber-500/30 bg-amber-500/10 px-4 py-1 text-xs font-semibold uppercase tracking-widest text-amber-400 mb-6">
Quick start
</span>
<h2 className="text-4xl font-bold tracking-tight text-white mb-4">
One command to start
</h2>
<p className="text-slate-400 mb-10">
Backerup runs as a Docker container. Give it access to your Docker socket and you're done.
</p>
<div className="rounded-2xl border border-white/6 glass overflow-hidden text-left">
<div className="flex items-center gap-2 border-b border-white/6 px-5 py-3 bg-white/2">
<span className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500/70" />
<span className="ml-3 text-xs font-mono text-slate-500">terminal</span>
</div>
<pre className="overflow-x-auto p-6 text-sm font-mono leading-7 text-slate-300">
<span className="text-slate-600 select-none"># Pull &amp; run replace /path/to/backups with your backup destination</span>{"\n"}
<span className="text-cyan-500 select-none">$ </span>
<span>{"docker run -d \\"}</span>{"\n"}
<span className="text-slate-500 select-none">{" "}</span>
<span>{"--name backerup \\"}</span>{"\n"}
<span className="text-slate-500 select-none">{" "}</span>
<span>{`-p ${LINKS.uiPort}:${LINKS.uiPort} \\`}</span>{"\n"}
<span className="text-slate-500 select-none">{" "}</span>
<span>{"-v /var/run/docker.sock:/var/run/docker.sock \\"}</span>{"\n"}
<span className="text-slate-500 select-none">{" "}</span>
<span>{"-v /path/to/backups:/data \\"}</span>{"\n"}
<span className="text-slate-500 select-none">{" "}</span>
<span className="text-slate-200">{LINKS.dockerImage}</span>{"\n"}
<span className="text-emerald-400">{`✓ Open http://localhost:${LINKS.uiPort} to get started`}</span>
</pre>
</div>
</div>
</AnimateIn>
</section>
{/* ═══ Final CTA ══════════════════════════════════════════ */}
<section className="px-6 py-24">
<div className="mx-auto max-w-4xl">
<AnimateIn variant="scale-up">
<div
className="relative overflow-hidden rounded-3xl p-px"
style={{ background: "linear-gradient(135deg, rgba(6,182,212,0.4), rgba(139,92,246,0.4), rgba(6,182,212,0.4))" }}
>
<div className="relative rounded-3xl px-10 py-16 text-center" style={{ background: "#060810" }}>
{/* Glow */}
<div
className="pointer-events-none absolute inset-0 opacity-20"
style={{ background: "radial-gradient(ellipse at center top, rgba(6,182,212,0.4), transparent 60%)" }}
/>
<div className="relative">
<h2 className="text-4xl font-extrabold tracking-tight text-white sm:text-5xl">
Start backing up today.
</h2>
<p className="mx-auto mt-5 max-w-xl text-lg text-slate-400">
Open source, self-hosted, and free forever. Share your container configs with the
community or keep everything private on your own infrastructure.
</p>
<div className="mt-10 flex flex-wrap justify-center gap-4">
<a
href={LINKS.github}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-7 py-3.5 text-sm font-semibold text-white transition-all hover:bg-white/10 hover:scale-105"
>
<GitHubIcon />
Star on GitHub
</a>
<Link
href="/share"
className="rounded-xl px-7 py-3.5 text-sm font-bold text-slate-900 transition-all hover:brightness-110 hover:scale-105 active:scale-95"
style={{ background: "linear-gradient(135deg, #22d3ee, #06b6d4)" }}
>
Share a config
</Link>
<Link
href="/browse"
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-7 py-3.5 text-sm font-semibold text-slate-200 transition-all hover:bg-white/10 hover:scale-105"
>
Browse configs
</Link>
</div>
</div>
</div>
</div>
</AnimateIn>
</div> </div>
</section> </section>
</> </>
); );
} }
+161 -53
View File
@@ -12,6 +12,22 @@ function formatBytes(n: number) {
return `${(n / 1024 / 1024).toFixed(2)} MB`; return `${(n / 1024 / 1024).toFixed(2)} MB`;
} }
function UploadIcon() {
return (
<svg viewBox="0 0 24 24" className="w-10 h-10" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 24 24" className="w-10 h-10" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
export default function SharePage() { export default function SharePage() {
const router = useRouter(); const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -22,9 +38,9 @@ export default function SharePage() {
const [existingGroups, setExistingGroups] = useState<string[]>([]); const [existingGroups, setExistingGroups] = useState<string[]>([]);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Pre-fetch existing group names for autocomplete
useEffect(() => { useEffect(() => {
fetch("/api/files") fetch("/api/files")
.then((r) => r.json()) .then((r) => r.json())
@@ -34,7 +50,7 @@ export default function SharePage() {
).sort(); ).sort();
setExistingGroups(groups); setExistingGroups(groups);
}) })
.catch(() => { }); .catch(() => {});
}, []); }, []);
function pickFile(f: File) { function pickFile(f: File) {
@@ -62,22 +78,34 @@ export default function SharePage() {
e.preventDefault(); e.preventDefault();
if (!file) return; if (!file) return;
setUploading(true); setUploading(true);
setProgress(0);
setError(null); setError(null);
// Animate a fake progress bar for perceived speed
const interval = setInterval(() => {
setProgress((p) => (p < 85 ? p + Math.random() * 8 : p));
}, 200);
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
if (displayName.trim()) formData.append("displayName", displayName.trim()); if (displayName.trim()) formData.append("displayName", displayName.trim());
formData.append("description", description); formData.append("description", description);
if (group.trim()) formData.append("group", group.trim()); if (group.trim()) formData.append("group", group.trim());
const res = await fetch("/api/upload", { method: "POST", body: formData }); const res = await fetch("/api/upload", { method: "POST", body: formData });
const data = (await res.json()) as { token?: string; error?: string }; const data = (await res.json()) as { token?: string; error?: string };
if (!res.ok || !data.token) { if (!res.ok || !data.token) throw new Error(data.error ?? "Upload failed");
throw new Error(data.error ?? "Upload failed");
} clearInterval(interval);
setProgress(100);
await new Promise((r) => setTimeout(r, 400));
router.push(`/files/${data.token}`); router.push(`/files/${data.token}`);
} catch (err) { } catch (err) {
clearInterval(interval);
setError(err instanceof Error ? err.message : "Unknown error"); setError(err instanceof Error ? err.message : "Unknown error");
setUploading(false); setUploading(false);
setProgress(0);
} }
} }
@@ -85,24 +113,32 @@ export default function SharePage() {
return ( return (
<div className="mx-auto max-w-2xl px-6 py-16"> <div className="mx-auto max-w-2xl px-6 py-16">
{/* Header */}
<div className="mb-10"> <div className="mb-10">
<h1 className="text-3xl font-bold text-white">Share a file</h1> <p className="text-xs font-semibold uppercase tracking-widest text-cyan-400 mb-2">
<p className="mt-2 text-slate-400"> Share
</p>
<h1 className="text-4xl font-extrabold tracking-tight text-white">
Share a file
</h1>
<p className="mt-3 text-slate-400 leading-relaxed">
Upload a{" "} Upload a{" "}
<span className="font-mono text-slate-300">.backerup</span> snapshot or{" "} <code className="font-mono text-slate-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">.backerup</code>{" "}
<span className="font-mono text-slate-300">.json</span> container config. snapshot or{" "}
Anyone with the link can download it. <code className="font-mono text-slate-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">.json</code>{" "}
container config. Anyone with the link can browse and download it.
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Drop zone */} {/* ── Drop zone ── */}
<div <div
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed px-8 py-16 text-center transition ${dragging className={`relative flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed px-8 py-14 text-center transition-all ${
? "border-cyan-400 bg-cyan-500/10" dragging
? "border-cyan-400 bg-cyan-500/8 scale-[1.01]"
: file : file
? "border-emerald-500/50 bg-emerald-500/5" ? "border-emerald-500/50 bg-emerald-500/5"
: "border-slate-700 bg-slate-900/50 hover:border-slate-600" : "border-white/10 bg-white/2 hover:border-white/20 hover:bg-white/4"
}`} }`}
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }} onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
@@ -116,39 +152,57 @@ export default function SharePage() {
className="hidden" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) pickFile(f); }} onChange={(e) => { const f = e.target.files?.[0]; if (f) pickFile(f); }}
/> />
{file ? ( {file ? (
<> <>
<div className="text-4xl">{isBackerup ? "📦" : "🗂️"}</div> <div className="text-emerald-400 mb-3">
<p className="mt-3 font-semibold text-white">{file.name}</p> <CheckIcon />
<p className="mt-1 text-xs text-slate-400"> </div>
<p className="font-semibold text-white">{file.name}</p>
<p className="mt-1 text-sm text-slate-400">
{formatBytes(file.size)} ·{" "} {formatBytes(file.size)} ·{" "}
<button <button
type="button" type="button"
className="text-cyan-400 hover:underline" className="text-cyan-400 hover:text-cyan-300 transition-colors underline"
onClick={(e) => { e.stopPropagation(); setFile(null); }} onClick={(e) => { e.stopPropagation(); setFile(null); setError(null); }}
> >
remove remove
</button> </button>
</p> </p>
<div className="mt-3 flex flex-wrap items-center justify-center gap-2">
<span
className={`rounded-full px-3 py-1 text-xs font-semibold border ${
isBackerup
? "bg-cyan-500/12 border-cyan-500/30 text-cyan-400"
: "bg-violet-500/12 border-violet-500/30 text-violet-400"
}`}
>
{isBackerup ? ".backerup archive" : ".json config"}
</span>
</div>
</> </>
) : ( ) : (
<> <>
<div className="text-4xl text-slate-600">📁</div> <div className={`mb-4 transition-colors ${dragging ? "text-cyan-400" : "text-slate-600"}`}>
<p className="mt-4 text-sm font-medium text-slate-300"> <UploadIcon />
Drop a file here or click to browse </div>
<p className="font-semibold text-slate-300">
{dragging ? "Drop it!" : "Drop a file or click to browse"}
</p> </p>
<p className="mt-1 text-xs text-slate-500"> <p className="mt-1.5 text-xs text-slate-600">
Accepts <span className="font-mono">.backerup</span> and{" "} Accepts{" "}
<span className="font-mono">.json</span> &mdash; up to 500 MB <code className="font-mono text-slate-500">.backerup</code> and{" "}
<code className="font-mono text-slate-500">.json</code> up to 500 MB
</p> </p>
</> </>
)} )}
</div> </div>
{/* Display name */} {/* ── Display name ── */}
<div> <div className="space-y-1.5">
<label className="mb-1.5 block text-sm font-medium text-slate-300" htmlFor="displayName"> <label className="block text-sm font-medium text-slate-300" htmlFor="displayName">
Display name <span className="text-slate-600">(optional)</span> Display name{" "}
<span className="text-slate-600 font-normal">(optional)</span>
</label> </label>
<input <input
id="displayName" id="displayName"
@@ -157,15 +211,16 @@ export default function SharePage() {
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
placeholder="e.g. Production Postgres backup" placeholder="e.g. Production Postgres backup"
maxLength={120} maxLength={120}
className="w-full rounded-xl border border-slate-700 bg-slate-900 px-4 py-3 text-sm text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500 transition-colors" className="w-full rounded-xl border border-white/8 bg-white/4 px-4 py-3 text-sm text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500/50 focus:bg-white/6 transition-all"
/> />
<p className="mt-1 text-xs text-slate-600">Shown instead of the raw filename.</p> <p className="text-xs text-slate-600">Shown on the browse page instead of the raw filename.</p>
</div> </div>
{/* Group */} {/* ── Group ── */}
<div> <div className="space-y-1.5">
<label className="mb-1.5 block text-sm font-medium text-slate-300" htmlFor="group"> <label className="block text-sm font-medium text-slate-300" htmlFor="group">
Group <span className="text-slate-600">(optional)</span> Group{" "}
<span className="text-slate-600 font-normal">(optional)</span>
</label> </label>
<input <input
id="group" id="group"
@@ -175,53 +230,106 @@ export default function SharePage() {
onChange={(e) => setGroup(e.target.value)} onChange={(e) => setGroup(e.target.value)}
placeholder="e.g. production, homelab, postgres" placeholder="e.g. production, homelab, postgres"
maxLength={60} maxLength={60}
className="w-full rounded-xl border border-slate-700 bg-slate-900 px-4 py-3 text-sm text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500 transition-colors" className="w-full rounded-xl border border-white/8 bg-white/4 px-4 py-3 text-sm text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500/50 focus:bg-white/6 transition-all"
/> />
{existingGroups.length > 0 && ( {existingGroups.length > 0 && (
<datalist id="group-list"> <datalist id="group-list">
{existingGroups.map((g) => ( {existingGroups.map((g) => <option key={g} value={g} />)}
<option key={g} value={g} />
))}
</datalist> </datalist>
)} )}
<p className="mt-1 text-xs text-slate-600"> {existingGroups.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{existingGroups.map((g) => (
<button
key={g}
type="button"
onClick={() => setGroup(g === group ? "" : g)}
className={`rounded-full px-2.5 py-0.5 text-xs transition-all ${
group === g
? "bg-violet-500/20 border border-violet-500/40 text-violet-300"
: "bg-white/4 border border-white/8 text-slate-500 hover:text-slate-300"
}`}
>
{g}
</button>
))}
</div>
)}
<p className="text-xs text-slate-600">
Tag this upload so others can filter by group on the browse page. Tag this upload so others can filter by group on the browse page.
</p> </p>
</div> </div>
{/* Description */} {/* ── Description ── */}
<div> <div className="space-y-1.5">
<label className="mb-1.5 block text-sm font-medium text-slate-300" htmlFor="desc"> <label className="block text-sm font-medium text-slate-300" htmlFor="desc">
Description <span className="text-slate-600">(optional)</span> Description{" "}
<span className="text-slate-600 font-normal">(optional)</span>
</label> </label>
<textarea <textarea
id="desc" id="desc"
rows={3} rows={3}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="e.g. Production Postgres 16 with TimescaleDB extension" placeholder="e.g. Production Postgres 16 with TimescaleDB extension, includes all data volumes"
className="w-full rounded-xl border border-slate-700 bg-slate-900 px-4 py-3 text-sm text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500 transition-colors" className="w-full rounded-xl border border-white/8 bg-white/4 px-4 py-3 text-sm text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500/50 focus:bg-white/6 transition-all resize-none"
/> />
</div> </div>
{/* Warning for .backerup */} {/* ── Warning for .backerup ── */}
{isBackerup && ( {isBackerup && (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4 text-xs text-amber-300"> <div className="flex gap-3 rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
<strong>Heads up:</strong> .backerup files may contain volume data. Make sure <span className="text-amber-400 shrink-0 mt-0.5">
you are not sharing sensitive database contents or secrets. <svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</span>
<p className="text-xs text-amber-300 leading-relaxed">
<strong className="font-semibold">Heads up:</strong> .backerup files may contain volume
data. Ensure you are not sharing sensitive database contents, secrets, or private keys.
</p>
</div> </div>
)} )}
{/* ── Error ── */}
{error && ( {error && (
<p className="rounded-lg bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</p> <div className="flex items-start gap-3 rounded-xl border border-red-500/20 bg-red-500/8 px-4 py-3">
<span className="text-red-400 shrink-0 mt-0.5">
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<circle cx="12" cy="12" r="10" /><path d="m15 9-6 6m0-6 6 6" />
</svg>
</span>
<p className="text-sm text-red-400">{error}</p>
</div>
)} )}
{/* ── Progress bar ── */}
{uploading && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>Uploading</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-white/5 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progress}%`,
background: "linear-gradient(90deg, #22d3ee, #818cf8)",
}}
/>
</div>
</div>
)}
{/* ── Submit ── */}
<button <button
type="submit" type="submit"
disabled={!file || uploading} disabled={!file || uploading}
className="w-full rounded-xl bg-cyan-500 py-3 text-sm font-bold text-slate-900 transition hover:bg-cyan-400 disabled:cursor-not-allowed disabled:opacity-40" className="w-full rounded-xl py-3.5 text-sm font-bold text-slate-900 transition-all hover:brightness-110 hover:scale-[1.01] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:scale-100"
style={{ background: "linear-gradient(135deg, #22d3ee, #06b6d4)" }}
> >
{uploading ? "Uploading…" : "Upload & get link"} {uploading ? "Uploading…" : "Upload & get share link"}
</button> </button>
</form> </form>
</div> </div>
+874 -4603
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
[
{
"id": "00c94c52-f312-4152-a6ee-e3ce0b23b5f8",
"username": "admin",
"passwordHash": "827c32b2234fc18911ec8579ad6863df63b8cc3835b1f3a3f04016dfcd46f22e609cc3c99d5e09959ff8f2787f4169206ad5a9ca83beec2e70c14f8cca110cc8",
"salt": "c2030b38b34b2810f715453350100729",
"role": "admin",
"createdAt": "2026-05-12T12:46:52.082Z"
}
]
+36
View File
@@ -0,0 +1,36 @@
import crypto from 'crypto'
// scrypt params: N=16384 gives ~100ms on modern hardware — deliberate slowdown
const SCRYPT_N = 16384
const SCRYPT_R = 8
const SCRYPT_P = 1
const KEY_LEN = 64
/** Hashes a password using scrypt. Returns the hex hash and a random hex salt. */
export async function hashPassword(password: string): Promise<{ hash: string; salt: string }> {
const salt = crypto.randomBytes(16).toString('hex')
const hash = await deriveKey(password, salt)
return { hash, salt }
}
/** Constant-time comparison to prevent timing attacks. */
export async function verifyPassword(
password: string,
hash: string,
salt: string,
): Promise<boolean> {
const derived = await deriveKey(password, salt)
const a = Buffer.from(derived, 'hex')
const b = Buffer.from(hash, 'hex')
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
function deriveKey(password: string, salt: string): Promise<string> {
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }, (err, key) => {
if (err) reject(err)
else resolve(key.toString('hex'))
})
})
}
+31
View File
@@ -0,0 +1,31 @@
/**
* ─────────────────────────────────────────────────────────────
* LINK CONFIGURATION
* Edit this file to update every link across the entire site.
* ─────────────────────────────────────────────────────────────
*/
export const LINKS = {
// ── GitHub ────────────────────────────────────────────────
/** Repository root — used in the nav, footer, and call-to-action buttons */
github: "https://github.com/evan-buss/backerup",
/** Releases / changelog page */
githubReleases: "https://github.com/evan-buss/backerup/releases",
/** Issue tracker */
githubIssues: "https://github.com/evan-buss/backerup/issues",
/** GitHub Discussions forum */
githubDiscussions: "https://github.com/evan-buss/backerup/discussions",
/** CONTRIBUTING.md guide */
githubContributing: "https://github.com/evan-buss/backerup/blob/main/CONTRIBUTING.md",
// ── Documentation ─────────────────────────────────────────
/** Primary documentation URL (separate from GitHub if hosted elsewhere) */
docs: "https://github.com/evan-buss/backerup",
// ── Docker ────────────────────────────────────────────────
/** Full Docker image reference shown in install snippets */
dockerImage: "ghcr.io/evan-buss/backerup:latest",
/** Default UI port the container exposes */
uiPort: "8080",
} as const;
export type Links = typeof LINKS;
+77
View File
@@ -0,0 +1,77 @@
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
export type Role = 'admin' | 'user'
export interface SessionPayload {
userId: string
username: string
role: Role
}
export const SESSION_COOKIE = 'backerup_session'
const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
// Dev fallback — NOT safe for production (use AUTH_SECRET env var)
const DEV_FALLBACK_SECRET = 'dev-secret-NOT-for-production-change-me-32chars!!'
export function getSecretKey(): Uint8Array {
const secret = process.env.AUTH_SECRET
if (!secret) {
if (process.env.NODE_ENV !== 'production') {
return new TextEncoder().encode(DEV_FALLBACK_SECRET)
}
throw new Error('AUTH_SECRET environment variable is required in production')
}
if (secret.length < 32) {
throw new Error('AUTH_SECRET must be at least 32 characters long')
}
return new TextEncoder().encode(secret)
}
/** Creates and stores a session JWT in an httpOnly cookie. */
export async function createSession(payload: SessionPayload): Promise<void> {
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS)
const token = await new SignJWT({ ...payload })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(expiresAt)
.sign(getSecretKey())
const cookieStore = await cookies()
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
expires: expiresAt,
path: '/',
})
}
/** Reads and validates the current session cookie. Returns null if missing/invalid. */
export async function getSession(): Promise<SessionPayload | null> {
const cookieStore = await cookies()
const token = cookieStore.get(SESSION_COOKIE)?.value
if (!token) return null
return verifyToken(token)
}
/** Deletes the session cookie. */
export async function deleteSession(): Promise<void> {
const cookieStore = await cookies()
cookieStore.delete(SESSION_COOKIE)
}
/** Verifies a JWT token string. Edge-runtime safe (uses jose). */
export async function verifyToken(token: string): Promise<SessionPayload | null> {
try {
const { payload } = await jwtVerify(token, getSecretKey(), { algorithms: ['HS256'] })
return {
userId: payload.userId as string,
username: payload.username as string,
role: payload.role as Role,
}
} catch {
return null
}
}
+140
View File
@@ -0,0 +1,140 @@
import path from 'path'
import fs from 'fs/promises'
import crypto from 'crypto'
import { hashPassword, verifyPassword } from './auth'
export type Role = 'admin' | 'user'
export interface User {
id: string
username: string
passwordHash: string
salt: string
role: Role
createdAt: string
}
/** User fields safe to expose to the client (no credentials). */
export type PublicUser = Omit<User, 'passwordHash' | 'salt'>
const DATA_DIR = path.join(process.cwd(), 'data')
const USERS_FILE = path.join(DATA_DIR, 'users.json')
async function readUsers(): Promise<User[]> {
try {
const text = await fs.readFile(USERS_FILE, 'utf-8')
return JSON.parse(text) as User[]
} catch {
return []
}
}
async function writeUsers(users: User[]): Promise<void> {
await fs.mkdir(DATA_DIR, { recursive: true })
await fs.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8')
}
function toPublic(user: User): PublicUser {
const { passwordHash: _, salt: __, ...pub } = user
return pub
}
export async function listUsers(): Promise<PublicUser[]> {
const users = await readUsers()
return users.map(toPublic)
}
export async function findUserByUsername(username: string): Promise<User | undefined> {
const users = await readUsers()
return users.find((u) => u.username.toLowerCase() === username.toLowerCase())
}
export async function findUserById(id: string): Promise<User | undefined> {
const users = await readUsers()
return users.find((u) => u.id === id)
}
export async function createUser(
username: string,
password: string,
role: Role,
): Promise<PublicUser> {
if (!username.trim()) throw new Error('Username cannot be empty')
if (password.length < 8) throw new Error('Password must be at least 8 characters')
const users = await readUsers()
if (users.some((u) => u.username.toLowerCase() === username.toLowerCase())) {
throw new Error(`Username "${username}" is already taken`)
}
const { hash, salt } = await hashPassword(password)
const user: User = {
id: crypto.randomUUID(),
username: username.trim(),
passwordHash: hash,
salt,
role,
createdAt: new Date().toISOString(),
}
users.push(user)
await writeUsers(users)
return toPublic(user)
}
export async function deleteUser(id: string): Promise<void> {
const users = await readUsers()
const target = users.find((u) => u.id === id)
if (!target) throw new Error('User not found')
const remaining = users.filter((u) => u.id !== id)
const remainingAdmins = remaining.filter((u) => u.role === 'admin')
if (target.role === 'admin' && remainingAdmins.length === 0) {
throw new Error('Cannot delete the last admin account')
}
await writeUsers(remaining)
}
export async function updatePassword(id: string, newPassword: string): Promise<void> {
if (newPassword.length < 8) throw new Error('Password must be at least 8 characters')
const users = await readUsers()
const idx = users.findIndex((u) => u.id === id)
if (idx === -1) throw new Error('User not found')
const { hash, salt } = await hashPassword(newPassword)
users[idx].passwordHash = hash
users[idx].salt = salt
await writeUsers(users)
}
export async function authenticateUser(username: string, password: string): Promise<User | null> {
const user = await findUserByUsername(username)
if (!user) {
// Run a dummy derivation to keep constant time even when user not found
await hashPassword(password)
return null
}
const valid = await verifyPassword(password, user.passwordHash, user.salt)
return valid ? user : null
}
export async function countUsers(): Promise<number> {
const users = await readUsers()
return users.length
}
/**
* Seeds an initial admin user if no users exist yet.
* Reads ADMIN_USERNAME (default "admin") and ADMIN_PASSWORD from environment.
*/
export async function seedAdminIfEmpty(): Promise<void> {
const count = await countUsers()
if (count > 0) return
const password = process.env.ADMIN_PASSWORD
if (!password) return
const username = process.env.ADMIN_USERNAME ?? 'admin'
try {
await createUser(username, password, 'admin')
console.log(`[auth] Seeded initial admin user: "${username}"`)
} catch (e) {
console.error('[auth] Failed to seed admin user:', e)
}
}
+20
View File
@@ -1,5 +1,17 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const securityHeaders = [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "X-DNS-Prefetch-Control", value: "off" },
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
];
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
experimental: { experimental: {
@@ -7,6 +19,14 @@ const nextConfig: NextConfig = {
bodySizeLimit: "500mb", bodySizeLimit: "500mb",
}, },
}, },
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
}; };
export default nextConfig; export default nextConfig;
+1
View File
@@ -9,6 +9,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"jose": "^6.2.3",
"nanoid": "^5.1.11", "nanoid": "^5.1.11",
"next": "16.2.6", "next": "16.2.6",
"react": "19.2.4", "react": "19.2.4",
+114
View File
@@ -0,0 +1,114 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose'
// Dev fallback secret — must match lib/session.ts
const DEV_FALLBACK_SECRET = 'dev-secret-NOT-for-production-change-me-32chars!!'
function getSecretKey(): Uint8Array {
const secret = process.env.AUTH_SECRET ?? DEV_FALLBACK_SECRET
return new TextEncoder().encode(secret)
}
/** Paths (exact) that never require authentication. */
const PUBLIC_EXACT = new Set(['/', '/login'])
/** Path prefixes that never require authentication. */
const PUBLIC_PREFIXES = [
'/files/', // share link pages — public by design
'/api/files/', // individual file info + download — share links must work
'/api/auth/', // auth endpoints themselves
'/_next/',
]
/** Path prefixes that require admin role in addition to being authenticated. */
const ADMIN_PREFIXES = ['/admin', '/api/admin']
function isPublic(pathname: string): boolean {
if (PUBLIC_EXACT.has(pathname)) return true
return PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))
}
function isAdminRoute(pathname: string): boolean {
return ADMIN_PREFIXES.some((p) => pathname.startsWith(p))
}
const SECURITY_HEADERS: Record<string, string> = {
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
'X-DNS-Prefetch-Control': 'off',
}
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
// Build the base response with security headers applied to every response
function withHeaders(res: NextResponse): NextResponse {
for (const [k, v] of Object.entries(SECURITY_HEADERS)) {
res.headers.set(k, v)
}
return res
}
// Static assets — just add security headers
if (pathname.startsWith('/_next/') || pathname === '/favicon.ico') {
return withHeaders(NextResponse.next())
}
// Auth disabled (no secret configured) — pass all traffic through
if (!process.env.AUTH_SECRET && process.env.NODE_ENV !== 'production') {
// Still add security headers in dev mode
return withHeaders(NextResponse.next())
}
// Public routes — no auth needed
if (isPublic(pathname)) {
return withHeaders(NextResponse.next())
}
// Attempt to validate session
const token = request.cookies.get('backerup_session')?.value
let payload: { userId?: string; username?: string; role?: string } | null = null
if (token) {
try {
const { payload: p } = await jwtVerify(token, getSecretKey(), { algorithms: ['HS256'] })
payload = p
} catch {
// expired or tampered — treat as unauthenticated
}
}
// Not authenticated
if (!payload) {
if (pathname.startsWith('/api/')) {
return withHeaders(NextResponse.json({ error: 'Unauthorized' }, { status: 401 }))
}
const loginUrl = request.nextUrl.clone()
loginUrl.pathname = '/login'
loginUrl.searchParams.set('from', pathname)
return NextResponse.redirect(loginUrl)
}
// Admin-only routes
if (isAdminRoute(pathname) && payload.role !== 'admin') {
if (pathname.startsWith('/api/')) {
return withHeaders(NextResponse.json({ error: 'Forbidden' }, { status: 403 }))
}
return withHeaders(NextResponse.redirect(new URL('/', request.url)))
}
// Authenticated — pass through
const response = NextResponse.next()
// Forward user info to server components/API routes via headers
response.headers.set('x-user-id', payload.userId ?? '')
response.headers.set('x-user-name', payload.username ?? '')
response.headers.set('x-user-role', payload.role ?? '')
return withHeaders(response)
}
export const config = {
matcher: ['/((?!_next/static|_next/image|public/).*)'],
}