mirror of
https://gitlab.w-hs.de/an14051/backerup-website.git
synced 2026-07-27 17:35:29 +00:00
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:
@@ -1,36 +1,88 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Backerup Website
|
||||
|
||||
## Getting Started
|
||||
The official website and sharing hub for [Backerup](https://github.com/evan-buss/backerup) — a self-hosted Docker container backup tool.
|
||||
|
||||
First, run the development server:
|
||||
Browse community-shared container configs and `.backerup` snapshots, upload your own, and copy restore commands in seconds.
|
||||
|
||||
## Features
|
||||
|
||||
- **Browse & search** — filter shared files by type, group, name, image, or description
|
||||
- **Upload & share** — share `.backerup` snapshots and `.json` container configs via a unique link
|
||||
- **One-click restore snippet** — Docker run commands generated from uploaded configs
|
||||
- **Authentication** — JWT-based sessions with scrypt password hashing and rate-limited login
|
||||
- **Admin panel** — create, delete, and manage user accounts with role-based access
|
||||
- **Security headers** — `X-Frame-Options`, `HSTS`, `CSP`-ready, and more on every response
|
||||
|
||||
## Quick Start (Development)
|
||||
|
||||
**Prerequisites:** [Bun](https://bun.sh) >= 1.0
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
cd frontend
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
The site is available at <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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readMeta } from '@/lib/store'
|
||||
import { getSession } from '@/lib/session'
|
||||
|
||||
export async function GET() {
|
||||
// Belt-and-suspenders auth check (middleware handles the primary check)
|
||||
const session = await getSession()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const records = await readMeta()
|
||||
return NextResponse.json(records)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { addFile } from '@/lib/store'
|
||||
import { getSession } from '@/lib/session'
|
||||
import type { FileRecord } from '@/lib/store'
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.backerup', '.json']
|
||||
const MAX_SIZE_BYTES = 500 * 1024 * 1024 // 500 MB
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Belt-and-suspenders auth check (middleware handles the primary check)
|
||||
const session = await getSession()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
let formData: FormData
|
||||
try {
|
||||
formData = await request.formData()
|
||||
|
||||
@@ -1,47 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import type { FileRecord } from "@/lib/store";
|
||||
|
||||
type SortOrder = "newest" | "oldest" | "name" | "size-desc" | "size-asc";
|
||||
type TypeFilter = "all" | "backerup" | "config";
|
||||
|
||||
function formatBytes(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(s: string) {
|
||||
return new Date(s).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<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({
|
||||
records: allRecords,
|
||||
groups,
|
||||
selectedGroup,
|
||||
}: {
|
||||
records: FileRecord[];
|
||||
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) {
|
||||
if (g === null) {
|
||||
router.push("/browse");
|
||||
} else {
|
||||
router.push(`/browse?group=${encodeURIComponent(g)}`);
|
||||
const filtered = useMemo(() => {
|
||||
let r = allRecords;
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
r = r.filter(
|
||||
(rec) =>
|
||||
rec.displayName?.toLowerCase().includes(q) ||
|
||||
rec.originalName.toLowerCase().includes(q) ||
|
||||
rec.description?.toLowerCase().includes(q) ||
|
||||
rec.containerName?.toLowerCase().includes(q) ||
|
||||
rec.image?.toLowerCase().includes(q) ||
|
||||
rec.group?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<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
|
||||
onClick={() => select(null)}
|
||||
className={`rounded-full px-4 py-1.5 text-xs font-semibold transition-colors ${selectedGroup === null
|
||||
onClick={() => setSearch("")}
|
||||
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 A–Z</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"
|
||||
: "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>
|
||||
))}
|
||||
|
||||
{groups.length > 0 && (
|
||||
<>
|
||||
<div className="h-5 w-px bg-white/8 mx-1" />
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => select(g)}
|
||||
className={`rounded-full px-4 py-1.5 text-xs font-semibold transition-colors ${selectedGroup === g
|
||||
? "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"
|
||||
onClick={() => setSelectedGroup(selectedGroup === g ? null : g)}
|
||||
className={`rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all ${
|
||||
selectedGroup === g
|
||||
? "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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,112 +1,37 @@
|
||||
import Link from "next/link";
|
||||
import { readMeta, listGroups } from "@/lib/store";
|
||||
import type { FileRecord } from "@/lib/store";
|
||||
import BrowseClient from "./BrowseClient";
|
||||
|
||||
function formatBytes(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: FileRecord["type"] }) {
|
||||
return type === "backerup" ? (
|
||||
<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;
|
||||
export default async function BrowsePage() {
|
||||
const [allRecords, groups] = await Promise.all([readMeta(), listGroups()]);
|
||||
|
||||
const records = selectedGroup
|
||||
? allRecords.filter((r) => r.group === selectedGroup)
|
||||
: allRecords;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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">
|
||||
{records.length} file{records.length !== 1 ? "s" : ""}
|
||||
{selectedGroup ? ` in "${selectedGroup}"` : " shared by the community"}.
|
||||
Docker configs and backups shared by the community. Find, inspect, and restore in
|
||||
seconds.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Group filter bar */}
|
||||
{groups.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
{/* Client section: search + filter + cards */}
|
||||
<BrowseClient records={allRecords} groups={groups} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export default function CopyLinkButton({ token }: { token: string }) {
|
||||
function copy() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
const url = `${window.location.origin}/files/${token}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
alert("Link copied to clipboard!");
|
||||
});
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2500);
|
||||
} catch {
|
||||
// Fallback for browsers that deny clipboard access
|
||||
const el = document.createElement("textarea");
|
||||
el.value = url;
|
||||
el.style.position = "fixed";
|
||||
el.style.opacity = "0";
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(el);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2500);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
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
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,15 @@ function formatBytes(n: number) {
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function MetaRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<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({
|
||||
params,
|
||||
}: {
|
||||
@@ -20,98 +29,135 @@ export default async function FileDetailPage({
|
||||
|
||||
const isBackerup = record.type === "backerup";
|
||||
const downloadUrl = `/api/files/${token}/download`;
|
||||
const title = record.displayName ?? record.containerName ?? record.originalName;
|
||||
|
||||
return (
|
||||
<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 to browse
|
||||
{/* Back */}
|
||||
<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>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Header card */}
|
||||
<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 ? (
|
||||
<span className="rounded-full bg-cyan-500/15 px-3 py-1 text-xs font-semibold text-cyan-400">
|
||||
.backerup
|
||||
<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 archive
|
||||
</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
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-sm text-slate-500">{record.originalName}</span>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="mt-4 text-3xl font-bold text-white">
|
||||
{record.displayName ?? record.containerName ?? record.originalName}
|
||||
<span className="font-mono text-xs text-slate-600 ml-auto">{record.originalName}</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-white leading-snug">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{/* 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 && (
|
||||
<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>
|
||||
|
||||
{/* Metadata grid */}
|
||||
<div className="mt-8 rounded-2xl border border-slate-800 bg-slate-900/60 p-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">File size</span>
|
||||
<span className="text-sm font-mono text-slate-300">{formatBytes(record.size)}</span>
|
||||
{/* Metadata */}
|
||||
<div className="rounded-2xl border border-white/6 glass px-6 py-2 mb-6">
|
||||
<MetaRow label="File size" value={formatBytes(record.size)} />
|
||||
<MetaRow
|
||||
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 className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">Uploaded</span>
|
||||
<span className="text-sm text-slate-300">
|
||||
{new Date(record.uploadedAt).toLocaleString()}
|
||||
</span>
|
||||
|
||||
{/* Docker restore snippet for configs */}
|
||||
{!isBackerup && record.image && (
|
||||
<div className="rounded-2xl border border-white/6 glass overflow-hidden mb-6">
|
||||
<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>
|
||||
{record.group && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">Group</span>
|
||||
<span className="text-sm font-mono text-slate-300">{record.group}</span>
|
||||
<pre className="overflow-x-auto p-5 text-xs font-mono leading-6 text-slate-300">
|
||||
<span className="text-slate-600 select-none"># Pull the image used by this config</span>{"\n"}
|
||||
<span className="text-cyan-500 select-none">$ </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>
|
||||
)}
|
||||
{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 */}
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="space-y-3">
|
||||
<a
|
||||
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}
|
||||
>
|
||||
<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}
|
||||
</a>
|
||||
|
||||
{/* Copy link */}
|
||||
<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>
|
||||
|
||||
{/* Warning for .backerup */}
|
||||
{isBackerup && (
|
||||
<div className="mt-8 rounded-xl border border-amber-500/20 bg-amber-500/5 p-4 text-xs text-amber-300">
|
||||
<strong>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.
|
||||
<div className="mt-6 flex gap-3 rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
|
||||
<span className="text-amber-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">
|
||||
<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>
|
||||
|
||||
+275
-8
@@ -1,19 +1,286 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #0b0f1a;
|
||||
--foreground: #e2e8f0;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: #060810;
|
||||
--color-foreground: #e2e8f0;
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
|
||||
--animate-fade-in: fadeIn 0.8s ease-out both;
|
||||
--animate-slide-up: slideUp 0.8s ease-out both;
|
||||
--animate-glow: glow 3s ease-in-out infinite alternate;
|
||||
--animate-float: float 6s ease-in-out infinite;
|
||||
--animate-gradient: gradientShift 8s ease infinite;
|
||||
--animate-blink: blink 1s step-end infinite;
|
||||
--animate-spin-slow: spinSlow 25s linear infinite;
|
||||
--animate-pulse-slow: pulseSlow 5s ease-in-out infinite;
|
||||
--animate-shimmer: shimmerSlide 3s ease infinite;
|
||||
--animate-scan: scanLine 4s linear infinite;
|
||||
--animate-aurora-1: aurora1 18s ease-in-out infinite alternate;
|
||||
--animate-aurora-2: aurora2 23s ease-in-out infinite alternate-reverse;
|
||||
--animate-aurora-3: aurora3 15s ease-in-out infinite alternate;
|
||||
--animate-border-glow: borderGlow 3s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #060810;
|
||||
--foreground: #e2e8f0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(32px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
from {
|
||||
box-shadow: 0 0 20px rgba(6, 182, 212, 0.15), 0 0 40px rgba(6, 182, 212, 0.05);
|
||||
}
|
||||
to {
|
||||
box-shadow: 0 0 40px rgba(6, 182, 212, 0.45), 0 0 80px rgba(6, 182, 212, 0.2), 0 0 120px rgba(6, 182, 212, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
33% { transform: translateY(-14px); }
|
||||
66% { transform: translateY(-7px); }
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes spinSlow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulseSlow {
|
||||
0%, 100% { opacity: 0.12; transform: scale(0.97); }
|
||||
50% { opacity: 0.35; transform: scale(1.03); }
|
||||
}
|
||||
|
||||
@keyframes shimmerSlide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(200%); }
|
||||
}
|
||||
|
||||
@keyframes scanLine {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(500%); }
|
||||
}
|
||||
|
||||
/* ── Glassmorphism ─────────────────────────────────────────── */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.glass-heavy {
|
||||
background: rgba(6, 8, 16, 0.85);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* ── Gradient text ─────────────────────────────────────────── */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #22d3ee 0%, #a78bfa 55%, #22d3ee 100%);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: gradientShift 8s ease infinite;
|
||||
}
|
||||
|
||||
/* ── Grid background ───────────────────────────────────────── */
|
||||
.grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(6, 182, 212, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(6, 182, 212, 0.035) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
}
|
||||
|
||||
/* ── Card hover glow ───────────────────────────────────────── */
|
||||
.card-hover {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(6, 182, 212, 0.3) !important;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(6, 182, 212, 0.06),
|
||||
0 16px 48px rgba(0, 0, 0, 0.5),
|
||||
0 0 40px rgba(6, 182, 212, 0.08);
|
||||
}
|
||||
|
||||
/* ── Neon glow border ──────────────────────────────────────── */
|
||||
.neon-cyan {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(6, 182, 212, 0.5),
|
||||
0 0 16px rgba(6, 182, 212, 0.2);
|
||||
}
|
||||
|
||||
/* ── Shimmer overlay ───────────────────────────────────────── */
|
||||
.shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.shimmer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.04), transparent);
|
||||
animation: shimmerSlide 3s ease infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Terminal scan effect ──────────────────────────────────── */
|
||||
.terminal-scan {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.terminal-scan::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
background: linear-gradient(to bottom, transparent, rgba(6, 182, 212, 0.025), transparent);
|
||||
animation: scanLine 4s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Step connector line ───────────────────────────────────── */
|
||||
.step-connector {
|
||||
position: relative;
|
||||
}
|
||||
.step-connector::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: calc(100% + 0px);
|
||||
width: calc(100% - 40px);
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(6, 182, 212, 0.4), rgba(6, 182, 212, 0.05));
|
||||
}
|
||||
|
||||
/* ── Aurora blob keyframes ────────────────────────────────── */
|
||||
@keyframes aurora1 {
|
||||
0% { transform: translate( 0px, 0px) scale(1.00); }
|
||||
33% { transform: translate( 80px, -70px) scale(1.15); }
|
||||
66% { transform: translate(-50px, 55px) scale(0.90); }
|
||||
100% { transform: translate( 25px, -20px) scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes aurora2 {
|
||||
0% { transform: translate( 0px, 0px) scale(1.00); }
|
||||
33% { transform: translate(-75px, 65px) scale(0.88); }
|
||||
66% { transform: translate( 90px, -45px) scale(1.12); }
|
||||
100% { transform: translate(-25px, 30px) scale(0.95); }
|
||||
}
|
||||
|
||||
@keyframes aurora3 {
|
||||
0% { transform: translate( 0px, 0px) scale(1.00); }
|
||||
33% { transform: translate( 55px, 75px) scale(1.10); }
|
||||
66% { transform: translate(-65px, -55px) scale(0.92); }
|
||||
100% { transform: translate( 15px, 45px) scale(1.08); }
|
||||
}
|
||||
|
||||
/* ── Border glow pulse ─────────────────────────────────────── */
|
||||
@keyframes borderGlow {
|
||||
from { box-shadow: 0 0 12px rgba(6,182,212,0.3), 0 0 24px rgba(6,182,212,0.1); }
|
||||
to { box-shadow: 0 0 24px rgba(6,182,212,0.6), 0 0 60px rgba(6,182,212,0.25), 0 0 100px rgba(139,92,246,0.1); }
|
||||
}
|
||||
|
||||
/* ── Spotlight card ────────────────────────────────────────── */
|
||||
.spotlight {
|
||||
--mx: -9999px;
|
||||
--my: -9999px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.spotlight::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: radial-gradient(
|
||||
280px circle at var(--mx) var(--my),
|
||||
rgba(6, 182, 212, 0.09),
|
||||
transparent 70%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.spotlight:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.spotlight > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Nav link underline animation ──────────────────────────── */
|
||||
.nav-link {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, #22d3ee, #818cf8);
|
||||
transition: width 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.nav-link:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ─────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #060810;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
+159
-21
@@ -1,6 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import Link from "next/link";
|
||||
import { LINKS } from "@/lib/links";
|
||||
import { getSession } from "@/lib/session";
|
||||
import LogoutButton from "./components/LogoutButton";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -19,32 +22,115 @@ export const metadata: Metadata = {
|
||||
"Simple, reliable Docker container backups. Share and discover container configs and backup snapshots.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<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,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await getSession();
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col bg-[#0b0f1a] text-slate-200">
|
||||
{/* Nav */}
|
||||
<header className="sticky top-0 z-50 border-b border-slate-800 bg-[#0b0f1a]/90 backdrop-blur">
|
||||
<body className="min-h-full flex flex-col" style={{ background: "#060810", color: "#e2e8f0" }}>
|
||||
{/* ── Global ambient background ── */}
|
||||
<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">
|
||||
<Link href="/" className="flex items-center gap-2 font-bold text-lg text-white">
|
||||
<span className="rounded bg-cyan-500 px-1.5 py-0.5 text-xs font-mono text-slate-900">
|
||||
.backerup
|
||||
<Link href="/" className="flex items-center gap-2.5 group">
|
||||
<div
|
||||
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>Backerup</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm text-slate-400">
|
||||
<Link href="/browse" className="hover:text-white transition-colors">Browse</Link>
|
||||
<Link href="/share" className="hover:text-white transition-colors">Share</Link>
|
||||
|
||||
<nav className="flex items-center gap-1 text-sm font-medium">
|
||||
<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
|
||||
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
|
||||
</Link>
|
||||
@@ -52,20 +138,72 @@ export default function RootLayout({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
{/* ── Page content ── */}
|
||||
<main className="flex-1">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-slate-800 py-8 text-center text-xs text-slate-600">
|
||||
<p>
|
||||
Backerup — open-source Docker backup.{" "}
|
||||
<a
|
||||
href="https://github.com"
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||||
{/* ── Footer ── */}
|
||||
<footer className="mt-24 border-t border-white/5">
|
||||
<div className="mx-auto max-w-6xl px-6 py-16">
|
||||
<div className="grid gap-12 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Brand */}
|
||||
<div className="lg:col-span-2">
|
||||
<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
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -1,170 +1,605 @@
|
||||
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 = [
|
||||
{
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
body: "Runs entirely inside your Docker environment. No cloud dependencies, no data leaves your server unless you choose to share.",
|
||||
},
|
||||
];
|
||||
|
||||
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: "3", title: "Backup", body: "Backerup archives your volumes and metadata into a portable .backerup file." },
|
||||
{ n: "4", title: "Restore", body: "One click to restore in-place, rebuild config, or create a new container." },
|
||||
{
|
||||
n: "01",
|
||||
icon: <DownloadIcon />,
|
||||
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() {
|
||||
return (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden px-6 py-28 text-center">
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-[500px] w-[900px] rounded-full bg-cyan-500/10 blur-3xl" />
|
||||
{/* ═══ Hero ═══════════════════════════════════════════════ */}
|
||||
<section className="relative overflow-hidden px-6 pt-28 pb-20">
|
||||
{/* ── Animated aurora blobs ── */}
|
||||
<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 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">
|
||||
Open Source · Self-Hosted
|
||||
|
||||
{/* ── Hero text — above the fold, animate on page load ── */}
|
||||
<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>
|
||||
<h1 className="mt-6 text-5xl font-bold leading-tight tracking-tight text-white sm:text-6xl">
|
||||
Docker backups,{" "}
|
||||
<span className="text-cyan-400">done right.</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<p className="mt-6 text-lg text-slate-400">
|
||||
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.
|
||||
|
||||
{/* Subtitle */}
|
||||
<p
|
||||
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>
|
||||
<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
|
||||
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
|
||||
</Link>
|
||||
<Link
|
||||
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
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* Terminal preview */}
|
||||
<div className="relative mx-auto mt-16 max-w-2xl rounded-2xl border border-slate-700 bg-slate-900 text-left shadow-2xl">
|
||||
<div className="flex items-center gap-2 border-b border-slate-700 px-4 py-3">
|
||||
{/* ── Terminal card ── */}
|
||||
<div
|
||||
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-yellow-500/70" />
|
||||
<span className="h-3 w-3 rounded-full bg-green-500/70" />
|
||||
<span className="ml-4 text-xs text-slate-500 font-mono">backerup api</span>
|
||||
<span className="h-3 w-3 rounded-full bg-emerald-500/70" />
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="px-6 py-20">
|
||||
{/* ═══ Stats bar ══════════════════════════════════════════ */}
|
||||
<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">
|
||||
<h2 className="text-center text-3xl font-bold text-white">Everything you need</h2>
|
||||
<p className="mt-3 text-center text-slate-400">
|
||||
A complete backup lifecycle — all from a single self-hosted UI.
|
||||
</p>
|
||||
<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}
|
||||
<AnimateIn variant="fade-up">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<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">
|
||||
Features
|
||||
</span>
|
||||
<h3 className="font-semibold text-white">{s.title}</h3>
|
||||
<p className="text-sm text-slate-400">{s.body}</p>
|
||||
<h2 className="mt-5 text-4xl font-bold tracking-tight text-white">
|
||||
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>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
{/* Format callout */}
|
||||
<section className="px-6 py-20">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="rounded-2xl border border-cyan-500/20 bg-cyan-500/5 p-10 text-center">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
The <span className="font-mono text-cyan-400">.backerup</span> format
|
||||
{/* ═══ How it works ════════════════════════════════════════ */}
|
||||
<section className="px-6 py-24">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<AnimateIn variant="fade-up">
|
||||
<div className="mx-auto max-w-2xl text-center mb-16">
|
||||
<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>
|
||||
<p className="mt-4 text-slate-400 max-w-xl mx-auto">
|
||||
A single gzipped-tar file containing{" "}
|
||||
<span className="font-mono text-slate-300">config.json</span>,{" "}
|
||||
<span className="font-mono text-slate-300">manifest.json</span>, and{" "}
|
||||
<span className="font-mono text-slate-300">volumes/*.tar.gz</span>.
|
||||
Portable, transparent, and restorable without Backerup itself.
|
||||
</div>
|
||||
</AnimateIn>
|
||||
|
||||
<div className="relative grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Connector line between steps */}
|
||||
<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>
|
||||
<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
|
||||
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
|
||||
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
|
||||
</Link>
|
||||
</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 & 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>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+161
-53
@@ -12,6 +12,22 @@ function formatBytes(n: number) {
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -22,9 +38,9 @@ export default function SharePage() {
|
||||
const [existingGroups, setExistingGroups] = useState<string[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Pre-fetch existing group names for autocomplete
|
||||
useEffect(() => {
|
||||
fetch("/api/files")
|
||||
.then((r) => r.json())
|
||||
@@ -34,7 +50,7 @@ export default function SharePage() {
|
||||
).sort();
|
||||
setExistingGroups(groups);
|
||||
})
|
||||
.catch(() => { });
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function pickFile(f: File) {
|
||||
@@ -62,22 +78,34 @@ export default function SharePage() {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setProgress(0);
|
||||
setError(null);
|
||||
|
||||
// Animate a fake progress bar for perceived speed
|
||||
const interval = setInterval(() => {
|
||||
setProgress((p) => (p < 85 ? p + Math.random() * 8 : p));
|
||||
}, 200);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (displayName.trim()) formData.append("displayName", displayName.trim());
|
||||
formData.append("description", description);
|
||||
if (group.trim()) formData.append("group", group.trim());
|
||||
|
||||
const res = await fetch("/api/upload", { method: "POST", body: formData });
|
||||
const data = (await res.json()) as { token?: string; error?: string };
|
||||
if (!res.ok || !data.token) {
|
||||
throw new Error(data.error ?? "Upload failed");
|
||||
}
|
||||
if (!res.ok || !data.token) throw new Error(data.error ?? "Upload failed");
|
||||
|
||||
clearInterval(interval);
|
||||
setProgress(100);
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
router.push(`/files/${data.token}`);
|
||||
} catch (err) {
|
||||
clearInterval(interval);
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
setUploading(false);
|
||||
setProgress(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,24 +113,32 @@ export default function SharePage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-16">
|
||||
{/* Header */}
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl font-bold text-white">Share a file</h1>
|
||||
<p className="mt-2 text-slate-400">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-cyan-400 mb-2">
|
||||
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{" "}
|
||||
<span className="font-mono text-slate-300">.backerup</span> snapshot or{" "}
|
||||
<span className="font-mono text-slate-300">.json</span> container config.
|
||||
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">.backerup</code>{" "}
|
||||
snapshot or{" "}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Drop zone */}
|
||||
{/* ── Drop zone ── */}
|
||||
<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
|
||||
? "border-cyan-400 bg-cyan-500/10"
|
||||
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 ${
|
||||
dragging
|
||||
? "border-cyan-400 bg-cyan-500/8 scale-[1.01]"
|
||||
: file
|
||||
? "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()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
@@ -116,39 +152,57 @@ export default function SharePage() {
|
||||
className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) pickFile(f); }}
|
||||
/>
|
||||
|
||||
{file ? (
|
||||
<>
|
||||
<div className="text-4xl">{isBackerup ? "📦" : "🗂️"}</div>
|
||||
<p className="mt-3 font-semibold text-white">{file.name}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<div className="text-emerald-400 mb-3">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<p className="font-semibold text-white">{file.name}</p>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
{formatBytes(file.size)} ·{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-cyan-400 hover:underline"
|
||||
onClick={(e) => { e.stopPropagation(); setFile(null); }}
|
||||
className="text-cyan-400 hover:text-cyan-300 transition-colors underline"
|
||||
onClick={(e) => { e.stopPropagation(); setFile(null); setError(null); }}
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
</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>
|
||||
<p className="mt-4 text-sm font-medium text-slate-300">
|
||||
Drop a file here or click to browse
|
||||
<div className={`mb-4 transition-colors ${dragging ? "text-cyan-400" : "text-slate-600"}`}>
|
||||
<UploadIcon />
|
||||
</div>
|
||||
<p className="font-semibold text-slate-300">
|
||||
{dragging ? "Drop it!" : "Drop a file or click to browse"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Accepts <span className="font-mono">.backerup</span> and{" "}
|
||||
<span className="font-mono">.json</span> — up to 500 MB
|
||||
<p className="mt-1.5 text-xs text-slate-600">
|
||||
Accepts{" "}
|
||||
<code className="font-mono text-slate-500">.backerup</code> and{" "}
|
||||
<code className="font-mono text-slate-500">.json</code> — up to 500 MB
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Display name */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-300" htmlFor="displayName">
|
||||
Display name <span className="text-slate-600">(optional)</span>
|
||||
{/* ── Display name ── */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-slate-300" htmlFor="displayName">
|
||||
Display name{" "}
|
||||
<span className="text-slate-600 font-normal">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="displayName"
|
||||
@@ -157,15 +211,16 @@ export default function SharePage() {
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="e.g. Production Postgres backup"
|
||||
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>
|
||||
|
||||
{/* Group */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-300" htmlFor="group">
|
||||
Group <span className="text-slate-600">(optional)</span>
|
||||
{/* ── Group ── */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-slate-300" htmlFor="group">
|
||||
Group{" "}
|
||||
<span className="text-slate-600 font-normal">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="group"
|
||||
@@ -175,53 +230,106 @@ export default function SharePage() {
|
||||
onChange={(e) => setGroup(e.target.value)}
|
||||
placeholder="e.g. production, homelab, postgres"
|
||||
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 && (
|
||||
<datalist id="group-list">
|
||||
{existingGroups.map((g) => (
|
||||
<option key={g} value={g} />
|
||||
))}
|
||||
{existingGroups.map((g) => <option key={g} value={g} />)}
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-300" htmlFor="desc">
|
||||
Description <span className="text-slate-600">(optional)</span>
|
||||
{/* ── Description ── */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-slate-300" htmlFor="desc">
|
||||
Description{" "}
|
||||
<span className="text-slate-600 font-normal">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="desc"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="e.g. Production Postgres 16 with TimescaleDB extension"
|
||||
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"
|
||||
placeholder="e.g. Production Postgres 16 with TimescaleDB extension, includes all data volumes"
|
||||
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>
|
||||
|
||||
{/* Warning for .backerup */}
|
||||
{/* ── Warning for .backerup ── */}
|
||||
{isBackerup && (
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4 text-xs text-amber-300">
|
||||
<strong>Heads up:</strong> .backerup files may contain volume data. Make sure
|
||||
you are not sharing sensitive database contents or secrets.
|
||||
<div className="flex gap-3 rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
|
||||
<span className="text-amber-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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── 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
|
||||
type="submit"
|
||||
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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+874
-4603
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
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 = {
|
||||
output: "standalone",
|
||||
experimental: {
|
||||
@@ -7,6 +19,14 @@ const nextConfig: NextConfig = {
|
||||
bodySizeLimit: "500mb",
|
||||
},
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"jose": "^6.2.3",
|
||||
"nanoid": "^5.1.11",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -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/).*)'],
|
||||
}
|
||||
Reference in New Issue
Block a user