mirror of
https://gitlab.w-hs.de/an14051/backerup-website.git
synced 2026-07-27 17:35:29 +00:00
feat: add admin links management and updaterup page
- Implemented AdminLinksPage for managing link entries with admin role check. - Created API routes for fetching, updating, and deleting link entries. - Added ProductTabs component for navigation between Backerup and Updaterup. - Developed UpdaterupPage with detailed features, setup instructions, and statistics. - Introduced links database functionality for runtime-editable link overrides.
This commit is contained in:
@@ -41,3 +41,8 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
/frontend/data/uploads/
|
||||
/frontend/data/*.db
|
||||
/frontend/data/*.db-wal
|
||||
/frontend/data/*.db-shm
|
||||
/frontend/data/meta.json.migrated
|
||||
/frontend/data/users.json.migrated
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
export default function AdminTabLinkClient({
|
||||
href,
|
||||
label,
|
||||
exact,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
exact?: boolean
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const isActive = exact ? pathname === href : pathname.startsWith(href)
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`px-4 py-1.5 rounded-lg text-sm font-semibold transition-all ${
|
||||
isActive
|
||||
? 'bg-violet-500/20 text-violet-300 border border-violet-500/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { LinkEntry } from '@/lib/links-db'
|
||||
|
||||
interface Props {
|
||||
initialEntries: LinkEntry[]
|
||||
}
|
||||
|
||||
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%',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
}
|
||||
|
||||
export default function LinksClient({ initialEntries }: Props) {
|
||||
const [entries, setEntries] = useState<LinkEntry[]>(initialEntries)
|
||||
const [editing, setEditing] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const [resetting, setResetting] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [successMsg, setSuccessMsg] = useState('')
|
||||
|
||||
function flash(msg: string) {
|
||||
setSuccessMsg(msg)
|
||||
setTimeout(() => setSuccessMsg(''), 3000)
|
||||
}
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const res = await fetch('/api/admin/links')
|
||||
if (res.ok) setEntries((await res.json()) as LinkEntry[])
|
||||
}, [])
|
||||
|
||||
function startEdit(key: string, current: string) {
|
||||
setEditing((prev) => ({ ...prev, [key]: current }))
|
||||
}
|
||||
|
||||
function cancelEdit(key: string) {
|
||||
setEditing((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
setError('')
|
||||
}
|
||||
|
||||
async function handleSave(key: string) {
|
||||
const value = (editing[key] ?? '').trim()
|
||||
if (!value) {
|
||||
setError('Value cannot be empty')
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
setSaving(key)
|
||||
const res = await fetch('/api/admin/links', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value }),
|
||||
})
|
||||
const data = (await res.json()) as { error?: string }
|
||||
setSaving(null)
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? 'Failed to save')
|
||||
return
|
||||
}
|
||||
cancelEdit(key)
|
||||
await refresh()
|
||||
flash('Link updated — changes are live immediately')
|
||||
}
|
||||
|
||||
async function handleReset(key: string, defaultValue: string) {
|
||||
if (!confirm(`Reset "${key}" to default?\n\nDefault: ${defaultValue}`)) return
|
||||
setError('')
|
||||
setResetting(key)
|
||||
const res = await fetch(`/api/admin/links/${encodeURIComponent(key)}`, { method: 'DELETE' })
|
||||
const data = (await res.json()) as { error?: string }
|
||||
setResetting(null)
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? 'Failed to reset')
|
||||
return
|
||||
}
|
||||
cancelEdit(key)
|
||||
await refresh()
|
||||
flash(`"${key}" reset to default`)
|
||||
}
|
||||
|
||||
// Group entries
|
||||
const groups = Array.from(new Set(entries.map((e) => e.group)))
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-slate-400">
|
||||
Overrides are stored in the database and take effect immediately — no redeploy needed.
|
||||
Any key without an override falls back to the built-in default.
|
||||
</p>
|
||||
|
||||
{groups.map((group) => (
|
||||
<section key={group}>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3">
|
||||
{group}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{entries
|
||||
.filter((e) => e.group === group)
|
||||
.map((entry) => {
|
||||
const isEditing = entry.key in editing
|
||||
const hasOverride = entry.override !== null
|
||||
const isSaving = saving === entry.key
|
||||
const isResetting = resetting === entry.key
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="rounded-2xl p-5"
|
||||
style={{
|
||||
background: hasOverride
|
||||
? 'rgba(139,92,246,0.05)'
|
||||
: 'rgba(255,255,255,0.02)',
|
||||
border: hasOverride
|
||||
? '1px solid rgba(139,92,246,0.2)'
|
||||
: '1px solid rgba(255,255,255,0.07)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-slate-200">{entry.label}</span>
|
||||
{hasOverride && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
style={{ background: 'rgba(139,92,246,0.15)', color: '#c4b5fd', border: '1px solid rgba(139,92,246,0.3)' }}
|
||||
>
|
||||
<span className="w-1 h-1 rounded-full bg-violet-400" />
|
||||
overridden
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{entry.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{!isEditing && (
|
||||
<button
|
||||
onClick={() => startEdit(entry.key, entry.currentValue)}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-300 transition-all hover:text-white hover:bg-white/8"
|
||||
style={{ border: '1px solid rgba(255,255,255,0.1)' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{hasOverride && !isEditing && (
|
||||
<button
|
||||
onClick={() => handleReset(entry.key, entry.defaultValue)}
|
||||
disabled={isResetting}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-semibold transition-all disabled:opacity-50"
|
||||
style={{
|
||||
border: '1px solid rgba(239,68,68,0.3)',
|
||||
color: '#fca5a5',
|
||||
background: 'rgba(239,68,68,0.06)',
|
||||
}}
|
||||
title="Reset to built-in default"
|
||||
>
|
||||
{isResetting ? 'Resetting…' : 'Reset'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current value display */}
|
||||
{!isEditing && (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-mono text-slate-300 break-all"
|
||||
style={{ background: 'rgba(0,0,0,0.25)', border: '1px solid rgba(255,255,255,0.05)' }}
|
||||
>
|
||||
<span className="text-slate-500 shrink-0">→</span>
|
||||
<span className="truncate">{entry.currentValue}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit form */}
|
||||
{isEditing && (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={editing[entry.key]}
|
||||
onChange={(e) => setEditing((prev) => ({ ...prev, [entry.key]: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSave(entry.key)
|
||||
if (e.key === 'Escape') cancelEdit(entry.key)
|
||||
}}
|
||||
autoFocus
|
||||
style={inputStyle}
|
||||
placeholder={entry.defaultValue}
|
||||
/>
|
||||
{hasOverride && (
|
||||
<p className="text-xs text-slate-600">
|
||||
Default: <span className="font-mono text-slate-500">{entry.defaultValue}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSave(entry.key)}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-1.5 rounded-lg text-xs font-semibold text-slate-900 hover:brightness-110 transition-all disabled:opacity-60"
|
||||
style={{ background: 'linear-gradient(135deg, #22d3ee, #06b6d4)' }}
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => cancelEdit(entry.key)}
|
||||
className="px-4 py-1.5 rounded-lg text-xs font-semibold text-slate-400 hover:text-white transition-all"
|
||||
style={{ border: '1px solid rgba(255,255,255,0.1)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await getSession()
|
||||
if (!session || session.role !== 'admin') {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div
|
||||
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0"
|
||||
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 fillRule="evenodd" d="M8.34 1.804A1 1 0 019.32 1h1.36a1 1 0 01.98.804l.295 1.473c.497.144.971.342 1.416.587l1.25-.834a1 1 0 011.262.125l.962.962a1 1 0 01.125 1.262l-.834 1.25c.245.445.443.919.587 1.416l1.473.294a1 1 0 01.804.98v1.361a1 1 0 01-.804.98l-1.473.295a6.95 6.95 0 01-.587 1.416l.834 1.25a1 1 0 01-.125 1.262l-.962.962a1 1 0 01-1.262.125l-1.25-.834a6.953 6.953 0 01-1.416.587l-.294 1.473a1 1 0 01-.98.804H9.32a1 1 0 01-.98-.804l-.295-1.473a6.957 6.957 0 01-1.416-.587l-1.25.834a1 1 0 01-1.262-.125l-.962-.962a1 1 0 01-.125-1.262l.834-1.25a6.957 6.957 0 01-.587-1.416l-1.473-.294A1 1 0 011 10.68V9.32a1 1 0 01.804-.98l1.473-.295c.144-.497.342-.971.587-1.416l-.834-1.25a1 1 0 01.125-1.262l.962-.962A1 1 0 015.38 3.03l1.25.834a6.957 6.957 0 011.416-.587l.294-1.473zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">Admin</h1>
|
||||
<p className="text-sm text-slate-400">Site management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab nav */}
|
||||
<div className="flex items-center gap-1 rounded-xl border border-white/8 bg-white/2 p-1 w-fit">
|
||||
<AdminTabLink href="/admin" label="Users" exact />
|
||||
<AdminTabLink href="/admin/links" label="Links" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminTabLink({ href, label, exact }: { href: string; label: string; exact?: boolean }) {
|
||||
// This is a server component but Next.js doesn't give us pathname here easily.
|
||||
// We use a client wrapper below.
|
||||
return <AdminTabLinkClient href={href} label={label} exact={exact} />
|
||||
}
|
||||
|
||||
// Small client component just for active-state detection
|
||||
import AdminTabLinkClient from './AdminTabLinkClient'
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getAllLinkEntries } from '@/lib/links-db'
|
||||
import LinksClient from '../LinksClient'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin — Links — Backerup',
|
||||
}
|
||||
|
||||
export default async function AdminLinksPage() {
|
||||
const session = await getSession()
|
||||
if (!session || session.role !== 'admin') {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
const entries = await getAllLinkEntries()
|
||||
|
||||
return <LinksClient initialEntries={entries} />
|
||||
}
|
||||
@@ -16,27 +16,5 @@ export default async function AdminPage() {
|
||||
|
||||
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>
|
||||
)
|
||||
return <UsersClient initialUsers={users} currentUserId={session.userId} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { resetLink, type LinkKey } from '@/lib/links-db'
|
||||
import { LINKS } from '@/lib/links'
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ key: string }> },
|
||||
) {
|
||||
const session = await getSession()
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { key } = await params
|
||||
if (!(key in LINKS)) {
|
||||
return NextResponse.json({ error: 'Unknown link key' }, { status: 400 })
|
||||
}
|
||||
|
||||
await resetLink(key as LinkKey)
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getAllLinkEntries, setLink, type LinkKey } from '@/lib/links-db'
|
||||
import { LINKS } from '@/lib/links'
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession()
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
const entries = await getAllLinkEntries()
|
||||
return NextResponse.json(entries)
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
let body: { key?: string; value?: string }
|
||||
try {
|
||||
body = (await request.json()) as { key?: string; value?: string }
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const key = String(body.key ?? '').trim() as LinkKey
|
||||
const value = String(body.value ?? '').trim()
|
||||
|
||||
if (!key || !(key in LINKS)) {
|
||||
return NextResponse.json({ error: 'Unknown link key' }, { status: 400 })
|
||||
}
|
||||
if (!value) {
|
||||
return NextResponse.json({ error: 'Value cannot be empty' }, { status: 400 })
|
||||
}
|
||||
|
||||
await setLink(key, value)
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function ProductTabs() {
|
||||
const pathname = usePathname();
|
||||
const isUpdaterup = pathname.startsWith("/updaterup");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-xl border border-white/8 bg-white/3 p-1">
|
||||
<Link
|
||||
href="/"
|
||||
className={`px-4 py-1.5 rounded-lg text-sm font-semibold transition-all ${
|
||||
!isUpdaterup
|
||||
? "bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
Backerup
|
||||
</Link>
|
||||
<Link
|
||||
href="/updaterup"
|
||||
className={`px-4 py-1.5 rounded-lg text-sm font-semibold transition-all ${
|
||||
isUpdaterup
|
||||
? "bg-violet-500/20 text-violet-300 border border-violet-500/30 shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
Updaterup
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,39 +3,49 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LINKS } from "@/lib/links";
|
||||
|
||||
export interface TerminalLinks {
|
||||
dockerImage?: string;
|
||||
uiPort?: string;
|
||||
updaterupDockerImage?: string;
|
||||
}
|
||||
|
||||
interface Line {
|
||||
type: "comment" | "cmd" | "cmd-cont" | "output" | "success" | "error";
|
||||
text: string;
|
||||
copyable?: boolean;
|
||||
}
|
||||
|
||||
function buildDockerRunLines(): Line[] {
|
||||
function buildDockerRunLines(links: TerminalLinks): Line[] {
|
||||
const dockerImage = links.dockerImage ?? LINKS.dockerImage;
|
||||
const uiPort = links.uiPort ?? LINKS.uiPort;
|
||||
return [
|
||||
{ type: "comment", text: "# Quick start: Run Backerup directly" },
|
||||
{ type: "cmd", text: "docker run -d --name backerup \\", copyable: true },
|
||||
{ type: "cmd-cont", text: ` -p ${LINKS.uiPort}:80 \\`, copyable: true },
|
||||
{ type: "cmd-cont", text: ` -p ${uiPort}:80 \\`, copyable: true },
|
||||
{ type: "cmd-cont", text: " -v /var/run/docker.sock:/var/run/docker.sock \\", copyable: true },
|
||||
{ type: "cmd-cont", text: " -v ${BACKUP_DIR:-/backups}:/backups \\", copyable: true },
|
||||
{ type: "cmd-cont", text: " -v backerup-config:/app/config \\", copyable: true },
|
||||
{ type: "cmd-cont", text: " -e BACKUP_DIR=/backups \\", copyable: true },
|
||||
{ type: "cmd-cont", text: " -e BACKUP_CONFIG_DIR=/app/config \\", copyable: true },
|
||||
{ type: "cmd-cont", text: ` -e HOST_BACKUP_DIR=/backups \\`, copyable: true },
|
||||
{ type: "cmd-cont", text: ` ${LINKS.dockerImage}`, copyable: true },
|
||||
{ type: "cmd-cont", text: ` ${dockerImage}`, copyable: true },
|
||||
{ type: "output", text: "a7f82c3d1b09" },
|
||||
{ type: "success", text: "✓ Container started" },
|
||||
{ type: "success", text: "✓ Ready at http://localhost:8080" },
|
||||
{ type: "success", text: `✓ Ready at http://localhost:${uiPort}` },
|
||||
];
|
||||
}
|
||||
|
||||
function buildDockerComposeLines(): Line[] {
|
||||
function buildDockerComposeLines(links: TerminalLinks): Line[] {
|
||||
const dockerImage = links.dockerImage ?? LINKS.dockerImage;
|
||||
const uiPort = links.uiPort ?? LINKS.uiPort;
|
||||
return [
|
||||
{ type: "comment", text: "# Using docker-compose for easier management" },
|
||||
{ type: "cmd", text: "cat > docker-compose.yml << 'EOF'", copyable: false },
|
||||
{ type: "output", text: "services:" },
|
||||
{ type: "output", text: " backerup:" },
|
||||
{ type: "output", text: ` image: ${LINKS.dockerImage}` },
|
||||
{ type: "output", text: ` image: ${dockerImage}` },
|
||||
{ type: "output", text: " ports:" },
|
||||
{ type: "output", text: ` - \"${LINKS.uiPort}:80\"` },
|
||||
{ type: "output", text: ` - \"${uiPort}:80\"` },
|
||||
{ type: "output", text: " volumes:" },
|
||||
{ type: "output", text: " - /var/run/docker.sock:/var/run/docker.sock" },
|
||||
{ type: "output", text: " - ${BACKUP_DIR:-/backups}:/backups" },
|
||||
@@ -51,12 +61,56 @@ function buildDockerComposeLines(): Line[] {
|
||||
{ type: "success", text: "✓ docker-compose.yml created" },
|
||||
{ type: "cmd", text: "docker compose up -d", copyable: false },
|
||||
{ type: "output", text: "[+] Building 2.3s (12/12) FINISHED" },
|
||||
{ type: "success", text: `✓ Ready at http://localhost:${uiPort}` },
|
||||
];
|
||||
}
|
||||
|
||||
function buildUpdaterupRunLines(links: TerminalLinks): Line[] {
|
||||
const updaterupDockerImage = links.updaterupDockerImage ?? LINKS.updaterupDockerImage;
|
||||
return [
|
||||
{ type: "comment", text: "# Quick start: Run Updaterup directly" },
|
||||
{ type: "cmd", text: "docker run -d --name updaterup \\", copyable: true },
|
||||
{ type: "cmd-cont", text: " -p 8080:80 \\", copyable: true },
|
||||
{ type: "cmd-cont", text: " -v /var/run/docker.sock:/var/run/docker.sock \\", copyable: true },
|
||||
{ type: "cmd-cont", text: " -v updaterup-config:/app/config \\", copyable: true },
|
||||
{ type: "cmd-cont", text: ` ${updaterupDockerImage}`, copyable: true },
|
||||
{ type: "output", text: "b3c91d2e4f07" },
|
||||
{ type: "success", text: "✓ Container started" },
|
||||
{ type: "success", text: "✓ Ready at http://localhost:8080" },
|
||||
];
|
||||
}
|
||||
|
||||
function buildLines(mode: "docker-run" | "docker-compose" = "docker-run"): Line[] {
|
||||
return mode === "docker-run" ? buildDockerRunLines() : buildDockerComposeLines();
|
||||
function buildUpdaterupComposeLines(links: TerminalLinks): Line[] {
|
||||
const updaterupDockerImage = links.updaterupDockerImage ?? LINKS.updaterupDockerImage;
|
||||
return [
|
||||
{ type: "comment", text: "# Using docker-compose for Updaterup" },
|
||||
{ type: "cmd", text: "cat > docker-compose.yml << 'EOF'", copyable: false },
|
||||
{ type: "output", text: "services:" },
|
||||
{ type: "output", text: " updaterup:" },
|
||||
{ type: "output", text: ` image: ${updaterupDockerImage}` },
|
||||
{ type: "output", text: " ports:" },
|
||||
{ type: "output", text: " - \"8080:80\"" },
|
||||
{ type: "output", text: " volumes:" },
|
||||
{ type: "output", text: " - /var/run/docker.sock:/var/run/docker.sock" },
|
||||
{ type: "output", text: " - updaterup-config:/app/config" },
|
||||
{ type: "output", text: " restart: unless-stopped" },
|
||||
{ type: "output", text: "volumes:" },
|
||||
{ type: "output", text: " updaterup-config:" },
|
||||
{ type: "output", text: "EOF" },
|
||||
{ type: "success", text: "✓ docker-compose.yml created" },
|
||||
{ type: "cmd", text: "docker compose up -d", copyable: false },
|
||||
{ type: "output", text: "[+] Building 1.8s (10/10) FINISHED" },
|
||||
{ type: "success", text: "✓ Ready at http://localhost:8080" },
|
||||
];
|
||||
}
|
||||
|
||||
function buildLines(mode: "docker-run" | "docker-compose" | "updaterup" | "updaterup-compose" = "docker-run", links: TerminalLinks = {}): Line[] {
|
||||
switch (mode) {
|
||||
case "docker-compose": return buildDockerComposeLines(links);
|
||||
case "updaterup": return buildUpdaterupRunLines(links);
|
||||
case "updaterup-compose": return buildUpdaterupComposeLines(links);
|
||||
default: return buildDockerRunLines(links);
|
||||
}
|
||||
}
|
||||
|
||||
function lineSpeed(line: Line) {
|
||||
@@ -88,8 +142,8 @@ function linePrompt(line: Line): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
export default function TerminalTyper({ mode = "docker-run" }: { mode?: "docker-run" | "docker-compose" } = {}) {
|
||||
const LINES = buildLines(mode);
|
||||
export default function TerminalTyper({ mode = "docker-run", links = {} }: { mode?: "docker-run" | "docker-compose" | "updaterup" | "updaterup-compose"; links?: TerminalLinks } = {}) {
|
||||
const LINES = buildLines(mode, links);
|
||||
const [doneLines, setDoneLines] = useState<number[]>([]);
|
||||
const [current, setCurrent] = useState<{ idx: number; chars: number } | null>({ idx: 0, chars: 0 });
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -97,7 +151,7 @@ export default function TerminalTyper({ mode = "docker-run" }: { mode?: "docker-
|
||||
const copyToClipboard = () => {
|
||||
let textToCopy = "";
|
||||
|
||||
if (mode === "docker-compose") {
|
||||
if (mode === "docker-compose" || mode === "updaterup-compose") {
|
||||
// For docker-compose, copy the YAML content only
|
||||
const startIdx = LINES.findIndex(line => line.type === "output" && line.text === "services:");
|
||||
const endIdx = LINES.findIndex((line, i) => i > startIdx && line.type === "output" && line.text === "EOF");
|
||||
|
||||
+20
-11
@@ -2,8 +2,10 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import Link from "next/link";
|
||||
import { LINKS } from "@/lib/links";
|
||||
import { getLinks } from "@/lib/links-db";
|
||||
import { getSession } from "@/lib/session";
|
||||
import LogoutButton from "./components/LogoutButton";
|
||||
import ProductTabs from "./components/ProductTabs";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -44,6 +46,7 @@ export default async function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await getSession();
|
||||
const links = await getLinks();
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
@@ -70,7 +73,8 @@ export default async function RootLayout({
|
||||
{/* ── 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.5 group">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-2.5 group shrink-0">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ background: "linear-gradient(135deg, #22d3ee, #0891b2)" }}
|
||||
@@ -78,11 +82,16 @@ export default async function RootLayout({
|
||||
<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
|
||||
DoomLabs
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-1 text-sm font-medium">
|
||||
{/* Product tab switcher */}
|
||||
<div className="flex-1 flex justify-center px-4">
|
||||
<ProductTabs />
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-1 text-sm font-medium shrink-0">
|
||||
<Link
|
||||
href="/browse"
|
||||
className="nav-link px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
@@ -96,7 +105,7 @@ export default async function RootLayout({
|
||||
Share
|
||||
</Link>
|
||||
<a
|
||||
href={LINKS.gitea}
|
||||
href={links.gitea}
|
||||
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"
|
||||
@@ -162,7 +171,7 @@ export default async function RootLayout({
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<a
|
||||
href={LINKS.gitea}
|
||||
href={links.gitea}
|
||||
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"
|
||||
@@ -179,8 +188,8 @@ export default async function RootLayout({
|
||||
<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.giteaReleases} className="hover:text-cyan-400 transition-colors">Changelog</a></li>
|
||||
<li><a href={links.docs} className="hover:text-cyan-400 transition-colors">Documentation</a></li>
|
||||
<li><a href={links.giteaReleases} className="hover:text-cyan-400 transition-colors">Changelog</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -188,10 +197,10 @@ export default async function RootLayout({
|
||||
<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.gitea} className="hover:text-cyan-400 transition-colors">Gitea</a></li>
|
||||
<li><a href={LINKS.giteaIssues} className="hover:text-cyan-400 transition-colors">Issues</a></li>
|
||||
<li><a href={LINKS.giteaDiscussions} className="hover:text-cyan-400 transition-colors">Discussions</a></li>
|
||||
<li><a href={LINKS.giteaContributing} className="hover:text-cyan-400 transition-colors">Contributing</a></li>
|
||||
<li><a href={links.gitea} className="hover:text-cyan-400 transition-colors">Gitea</a></li>
|
||||
<li><a href={links.giteaIssues} className="hover:text-cyan-400 transition-colors">Issues</a></li>
|
||||
<li><a href={links.giteaDiscussions} className="hover:text-cyan-400 transition-colors">Discussions</a></li>
|
||||
<li><a href={links.giteaContributing} className="hover:text-cyan-400 transition-colors">Contributing</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+131
-5
@@ -4,6 +4,7 @@ import AnimateIn from "./components/AnimateIn";
|
||||
import CountUp from "./components/CountUp";
|
||||
import SpotlightCard from "./components/SpotlightCard";
|
||||
import { LINKS } from "@/lib/links";
|
||||
import { getLinks } from "@/lib/links-db";
|
||||
|
||||
/* ── SVG Icons ─────────────────────────────────────────────── */
|
||||
const CubeIcon = () => (
|
||||
@@ -149,7 +150,8 @@ const stats = [
|
||||
];
|
||||
|
||||
/* ── Page ───────────────────────────────────────────────────── */
|
||||
export default function LandingPage() {
|
||||
export default async function LandingPage() {
|
||||
const links = await getLinks();
|
||||
return (
|
||||
<>
|
||||
{/* ═══ Hero ═══════════════════════════════════════════════ */}
|
||||
@@ -245,7 +247,7 @@ export default function LandingPage() {
|
||||
</svg>
|
||||
</Link>
|
||||
<a
|
||||
href={LINKS.gitea}
|
||||
href={links.gitea}
|
||||
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"
|
||||
@@ -278,7 +280,7 @@ export default function LandingPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 min-h-[260px]">
|
||||
<TerminalTyper />
|
||||
<TerminalTyper links={{ dockerImage: links.dockerImage, uiPort: links.uiPort }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -508,13 +510,137 @@ export default function LandingPage() {
|
||||
<span className="ml-3 text-xs font-mono text-slate-500">docker-compose setup</span>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<TerminalTyper mode="docker-compose" />
|
||||
<TerminalTyper mode="docker-compose" links={{ dockerImage: links.dockerImage, uiPort: links.uiPort }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
</section>
|
||||
|
||||
{/* ═══ More tools ═════════════════════════════════════════ */}
|
||||
<section className="px-6 py-24">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<AnimateIn variant="fade-up">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<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">
|
||||
More tools
|
||||
</span>
|
||||
<h2 className="mt-5 text-4xl font-bold tracking-tight text-white">
|
||||
Built for Docker operators
|
||||
</h2>
|
||||
<p className="mt-4 text-slate-400 leading-relaxed">
|
||||
Backerup is part of a growing suite of self-hosted Docker management tools — all
|
||||
open-source, dark-mode-first, and designed to run alongside your containers.
|
||||
</p>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
|
||||
<div className="mt-14 grid gap-6 lg:grid-cols-2">
|
||||
{/* Backerup card */}
|
||||
<AnimateIn variant="fade-up" delay={0}>
|
||||
<SpotlightCard className="rounded-2xl border border-cyan-500/20 bg-gradient-to-br from-cyan-500/10 to-blue-600/10 p-8 h-full card-hover flex flex-col">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-cyan-500/10 text-cyan-400 shrink-0">
|
||||
<CubeIcon />
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-cyan-500/30 bg-cyan-500/10 px-3 py-1 text-xs font-semibold text-cyan-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400" />
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-5 text-xl font-bold text-white">Backerup</h3>
|
||||
<p className="mt-2 text-sm text-slate-400 leading-relaxed">
|
||||
Full Docker container backup manager. Archive volumes, bind mounts, and config
|
||||
into a single portable <code className="font-mono text-cyan-300 text-xs">.backerup</code> bundle.
|
||||
Restore, migrate, or share — all from a self-hosted UI.
|
||||
</p>
|
||||
<ul className="mt-5 space-y-2 text-sm text-slate-400 flex-1">
|
||||
{[
|
||||
"Full volume + config archives",
|
||||
"Config-only export for fast migrations",
|
||||
"One-click restore or container recreation",
|
||||
"Cron-based schedules with retention policies",
|
||||
].map((f) => (
|
||||
<li key={f} className="flex items-center gap-2.5">
|
||||
<svg viewBox="0 0 24 24" className="w-4 h-4 shrink-0 text-cyan-500" 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>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-7 flex gap-3">
|
||||
<a
|
||||
href={links.gitea}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-5 py-2.5 text-xs font-semibold text-slate-300 hover:bg-white/10 hover:text-white transition-all"
|
||||
>
|
||||
<GitHubIcon />
|
||||
Gitea
|
||||
</a>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
</AnimateIn>
|
||||
|
||||
{/* Updaterup card */}
|
||||
<AnimateIn variant="fade-up" delay={100}>
|
||||
<SpotlightCard className="rounded-2xl border border-violet-500/20 bg-gradient-to-br from-violet-500/10 to-purple-600/10 p-8 h-full card-hover flex flex-col">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-violet-500/10 text-violet-400 shrink-0">
|
||||
<RefreshIcon />
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-violet-500/30 bg-violet-500/10 px-3 py-1 text-xs font-semibold text-violet-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-violet-400" />
|
||||
New
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-5 text-xl font-bold text-white">Updaterup</h3>
|
||||
<p className="mt-2 text-sm text-slate-400 leading-relaxed">
|
||||
Docker container update manager. Continuously checks your running containers for
|
||||
new image versions and lets you update them with a single click — or automatically
|
||||
on a cron schedule.
|
||||
</p>
|
||||
<ul className="mt-5 space-y-2 text-sm text-slate-400 flex-1">
|
||||
{[
|
||||
"Digest-based update detection",
|
||||
"One-click update — preserves all original config",
|
||||
"Batch check & update all containers",
|
||||
"Named cron schedules with full audit history",
|
||||
"Live log viewer with level filtering",
|
||||
].map((f) => (
|
||||
<li key={f} className="flex items-center gap-2.5">
|
||||
<svg viewBox="0 0 24 24" className="w-4 h-4 shrink-0 text-violet-500" 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>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-7 flex gap-3">
|
||||
<Link
|
||||
href="/updaterup"
|
||||
className="flex items-center gap-2 rounded-xl px-5 py-2.5 text-xs font-bold text-white transition-all hover:brightness-110"
|
||||
style={{ background: "linear-gradient(135deg, #a78bfa, #7c3aed)" }}
|
||||
>
|
||||
Explore Updaterup →
|
||||
</Link>
|
||||
<a
|
||||
href={links.updaterupGitea}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-5 py-2.5 text-xs font-semibold text-slate-300 hover:bg-white/10 hover:text-white transition-all"
|
||||
>
|
||||
<GitHubIcon />
|
||||
Gitea
|
||||
</a>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
</AnimateIn>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ═══ Final CTA ══════════════════════════════════════════ */}
|
||||
<section className="px-6 py-24">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
@@ -540,7 +666,7 @@ export default function LandingPage() {
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap justify-center gap-4">
|
||||
<a
|
||||
href={LINKS.gitea}
|
||||
href={links.gitea}
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
import type { Metadata } from "next";
|
||||
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";
|
||||
import { getLinks } from "@/lib/links-db";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Updaterup — Docker Container Updates",
|
||||
description:
|
||||
"Automated Docker container update manager. Check running containers for new image versions and update them in one click — or on a cron schedule.",
|
||||
};
|
||||
|
||||
/* ── SVG Icons ─────────────────────────────────────────────── */
|
||||
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 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 MagnifyIcon = () => (
|
||||
<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 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
);
|
||||
const LayersIcon = () => (
|
||||
<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="M6.429 9.75L2.25 12l4.179 2.25m0-4.5l5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0l4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0l-5.571 3-5.571-3" />
|
||||
</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 BookIcon = () => (
|
||||
<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 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
);
|
||||
const TerminalIcon = () => (
|
||||
<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="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" />
|
||||
</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 EyeIcon = () => (
|
||||
<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="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
const ArrowUpIcon = () => (
|
||||
<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="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
|
||||
</svg>
|
||||
);
|
||||
const LinkIcon = () => (
|
||||
<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="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>
|
||||
);
|
||||
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: <CubeIcon />,
|
||||
gradient: "from-violet-500/15 to-purple-600/15",
|
||||
border: "border-violet-500/20",
|
||||
iconBg: "bg-violet-500/10",
|
||||
iconColor: "text-violet-400",
|
||||
title: "Container inventory",
|
||||
body: "Lists all running (and optionally stopped) containers with their current image, tag, and digest — your live fleet at a glance.",
|
||||
},
|
||||
{
|
||||
icon: <MagnifyIcon />,
|
||||
gradient: "from-purple-500/15 to-fuchsia-600/15",
|
||||
border: "border-purple-500/20",
|
||||
iconBg: "bg-purple-500/10",
|
||||
iconColor: "text-purple-400",
|
||||
title: "Digest-based detection",
|
||||
body: "Pulls the latest image manifest and compares SHA digests — no false positives from re-tagged images. Knows exactly when a real update is available.",
|
||||
},
|
||||
{
|
||||
icon: <RefreshIcon />,
|
||||
gradient: "from-fuchsia-500/15 to-violet-600/15",
|
||||
border: "border-fuchsia-500/20",
|
||||
iconBg: "bg-fuchsia-500/10",
|
||||
iconColor: "text-fuchsia-400",
|
||||
title: "One-click update",
|
||||
body: "Stops, removes, and recreates the container with the new image — preserving all original binds, ports, env vars, restart policy, and networks.",
|
||||
},
|
||||
{
|
||||
icon: <LayersIcon />,
|
||||
gradient: "from-indigo-500/15 to-violet-600/15",
|
||||
border: "border-indigo-500/20",
|
||||
iconBg: "bg-indigo-500/10",
|
||||
iconColor: "text-indigo-400",
|
||||
title: "Batch operations",
|
||||
body: "Check or update all containers at once with a single click. Updaterup handles each container sequentially to avoid Docker socket contention.",
|
||||
},
|
||||
{
|
||||
icon: <ClockIcon />,
|
||||
gradient: "from-violet-500/15 to-indigo-600/15",
|
||||
border: "border-violet-500/20",
|
||||
iconBg: "bg-violet-500/10",
|
||||
iconColor: "text-violet-400",
|
||||
title: "Cron schedules",
|
||||
body: "Create named schedules with check-only or check & auto-update modes, targeting specific containers or your entire fleet.",
|
||||
},
|
||||
{
|
||||
icon: <BookIcon />,
|
||||
gradient: "from-purple-500/15 to-violet-600/15",
|
||||
border: "border-purple-500/20",
|
||||
iconBg: "bg-purple-500/10",
|
||||
iconColor: "text-purple-400",
|
||||
title: "Audit history & live logs",
|
||||
body: "Full update audit trail with container, image, status, trigger, and digest. Plus a live ring-buffer log viewer with level and context filtering.",
|
||||
},
|
||||
];
|
||||
|
||||
const steps = [
|
||||
{
|
||||
n: "01",
|
||||
icon: <LinkIcon />,
|
||||
color: "bg-violet-500/20 text-violet-400",
|
||||
title: "Connect",
|
||||
body: "Mount the Docker socket. Updaterup auto-discovers all running containers immediately on startup.",
|
||||
},
|
||||
{
|
||||
n: "02",
|
||||
icon: <MagnifyIcon />,
|
||||
color: "bg-purple-500/20 text-purple-400",
|
||||
title: "Check",
|
||||
body: "Compare local image digests against the registry. Outdated containers are flagged instantly.",
|
||||
},
|
||||
{
|
||||
n: "03",
|
||||
icon: <EyeIcon />,
|
||||
color: "bg-fuchsia-500/20 text-fuchsia-400",
|
||||
title: "Review",
|
||||
body: "The dashboard shows all pending updates with image info and last-check timestamps.",
|
||||
},
|
||||
{
|
||||
n: "04",
|
||||
icon: <ArrowUpIcon />,
|
||||
color: "bg-indigo-500/20 text-indigo-400",
|
||||
title: "Update",
|
||||
body: "One click to update — Updaterup stops, removes, and recreates the container with zero config drift.",
|
||||
},
|
||||
];
|
||||
|
||||
const pills = [
|
||||
{ label: "Open source", color: "text-violet-400 bg-violet-500/10 border-violet-500/20" },
|
||||
{ label: "Zero cloud deps", color: "text-purple-400 bg-purple-500/10 border-purple-500/20" },
|
||||
{ label: "Self-hosted", color: "text-fuchsia-400 bg-fuchsia-500/10 border-fuchsia-500/20" },
|
||||
{ label: "Docker native", color: "text-indigo-400 bg-indigo-500/10 border-indigo-500/20" },
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ num: 100, suffix: "%", label: "Open source", sub: "source available" },
|
||||
{ num: 0, suffix: "", label: "Config drift", sub: "on container update" },
|
||||
{ num: 7, suffix: "", label: "UI pages", sub: "full management" },
|
||||
{ num: 0, suffix: "", label: "Cloud dependencies", sub: "runs on your infra" },
|
||||
];
|
||||
|
||||
/* ── Page ───────────────────────────────────────────────────── */
|
||||
export default async function UpdaterupPage() {
|
||||
const links = await getLinks();
|
||||
return (
|
||||
<>
|
||||
{/* ═══ 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(139,92,246,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(168,85,247,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(99,102,241,0.09) 0%, transparent 60%)",
|
||||
filter: "blur(55px)",
|
||||
animation: "aurora3 15s ease-in-out infinite alternate",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
))}
|
||||
</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" }}
|
||||
>
|
||||
Container updates,
|
||||
<br />
|
||||
<span
|
||||
className="bg-clip-text text-transparent"
|
||||
style={{ backgroundImage: "linear-gradient(135deg, #a78bfa, #7c3aed, #c026d3)" }}
|
||||
>
|
||||
automated.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* 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" }}
|
||||
>
|
||||
Updaterup watches your running containers for new image versions and lets you update
|
||||
them with a single click — or automatically on a cron schedule. Zero config drift,
|
||||
full audit history, all from a self-hosted UI.
|
||||
</p>
|
||||
|
||||
{/* 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" }}
|
||||
>
|
||||
<a
|
||||
href={links.updaterupGitea}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-2 rounded-xl px-7 py-3.5 text-sm font-bold text-white transition-all hover:brightness-110 hover:scale-105 active:scale-95 shadow-lg"
|
||||
style={{ background: "linear-gradient(135deg, #a78bfa, #7c3aed)" }}
|
||||
>
|
||||
<DownloadIcon />
|
||||
Get started
|
||||
</a>
|
||||
<Link
|
||||
href="/"
|
||||
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"
|
||||
>
|
||||
Also see Backerup
|
||||
<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.updaterupGitea}
|
||||
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 />
|
||||
Gitea
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 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" }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-2xl blur-xl opacity-40"
|
||||
style={{ background: "radial-gradient(ellipse, rgba(139,92,246,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-emerald-500/70" />
|
||||
<span className="ml-3 text-xs text-slate-500 font-mono tracking-wide">updaterup · quick start</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-violet-500 animate-pulse" />
|
||||
<span className="text-xs text-slate-600 font-mono">live</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 min-h-[260px]">
|
||||
<TerminalTyper mode="updaterup" links={{ updaterupDockerImage: links.updaterupDockerImage }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ═══ 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" id="features">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<AnimateIn variant="fade-up">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<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">
|
||||
Features
|
||||
</span>
|
||||
<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 update lifecycle — from continuous digest monitoring to
|
||||
scheduled auto-updates and a full audit trail, all from one 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>
|
||||
|
||||
{/* ═══ 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-purple-500/30 bg-purple-500/10 px-4 py-1 text-xs font-semibold uppercase tracking-widest text-purple-400">
|
||||
How it works
|
||||
</span>
|
||||
<h2 className="mt-5 text-4xl font-bold tracking-tight text-white">
|
||||
Up and running in minutes
|
||||
</h2>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
|
||||
<div className="relative grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div
|
||||
className="hidden lg:block absolute top-8 left-[12.5%] right-[12.5%] h-px"
|
||||
style={{ background: "linear-gradient(90deg, rgba(139,92,246,0.5), rgba(168,85,247,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>
|
||||
|
||||
{/* ═══ Zero config drift callout ══════════════════════════ */}
|
||||
<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-violet-500/30 bg-violet-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-widest text-violet-400 mb-6 self-start">
|
||||
How updates work
|
||||
</span>
|
||||
<h2 className="text-3xl font-bold text-white leading-snug">
|
||||
Zero config drift, guaranteed
|
||||
</h2>
|
||||
<p className="mt-4 text-slate-400 leading-relaxed">
|
||||
When Updaterup recreates a container, it reads the full{" "}
|
||||
<code className="font-mono text-violet-300 text-xs">docker inspect</code> output
|
||||
and rebuilds the container with the exact same configuration — nothing is lost
|
||||
or changed except the image digest.
|
||||
</p>
|
||||
<ul className="mt-6 space-y-3 text-sm text-slate-400">
|
||||
{[
|
||||
{ name: "Binds & volumes", desc: "all mount points preserved" },
|
||||
{ name: "Port mappings", desc: "identical host bindings" },
|
||||
{ name: "Env vars & labels", desc: "full environment intact" },
|
||||
{ name: "Restart policy & networks", desc: "same behaviour post-update" },
|
||||
].map((item) => (
|
||||
<li key={item.name} className="flex items-start gap-3">
|
||||
<span className="mt-0.5 text-violet-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">
|
||||
<a
|
||||
href={links.updaterupGitea}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl px-6 py-2.5 text-sm font-bold text-white transition-all hover:brightness-110"
|
||||
style={{ background: "linear-gradient(135deg, #a78bfa, #7c3aed)" }}
|
||||
>
|
||||
View source →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: update flow diagram */}
|
||||
<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 space-y-4">
|
||||
{[
|
||||
{ icon: "🔍", step: "inspect", desc: "docker inspect <container>", color: "text-violet-400" },
|
||||
{ icon: "⬇️", step: "pull", desc: "docker pull <image>:latest", color: "text-purple-400" },
|
||||
{ icon: "⏹", step: "stop", desc: "docker stop <container>", color: "text-amber-400" },
|
||||
{ icon: "🗑", step: "remove", desc: "docker rm <container>", color: "text-rose-400" },
|
||||
{ icon: "🚀", step: "create", desc: "docker run [original config]", color: "text-emerald-400" },
|
||||
].map(({ icon, step, desc, color }, i) => (
|
||||
<div key={step} className="flex items-center gap-3">
|
||||
<span className="text-slate-700 text-xs font-mono shrink-0">{String(i + 1).padStart(2, "0")}</span>
|
||||
<span>{icon}</span>
|
||||
<span className={`${color} font-semibold`}>{step}</span>
|
||||
<span className="text-slate-600 text-xs truncate">{desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ═══ Quick start ════════════════════════════════════════ */}
|
||||
<section className="px-6 py-20" id="quick-start">
|
||||
<AnimateIn variant="fade-up">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<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 mb-6">
|
||||
Quick start
|
||||
</span>
|
||||
<h2 className="text-4xl font-bold tracking-tight text-white mb-4">
|
||||
Setup with docker-compose
|
||||
</h2>
|
||||
<p className="text-slate-400 mb-10">
|
||||
The easiest way to get Updaterup running is with docker-compose for declarative, reproducible deployments.
|
||||
</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">docker-compose setup</span>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<TerminalTyper mode="updaterup-compose" links={{ updaterupDockerImage: links.updaterupDockerImage }} />
|
||||
</div>
|
||||
</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(139,92,246,0.4), rgba(168,85,247,0.4), rgba(99,102,241,0.4))" }}
|
||||
>
|
||||
<div className="relative rounded-3xl px-10 py-16 text-center" style={{ background: "#060810" }}>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-20"
|
||||
style={{ background: "radial-gradient(ellipse at center top, rgba(139,92,246,0.4), transparent 60%)" }}
|
||||
/>
|
||||
<div className="relative">
|
||||
<h2 className="text-4xl font-extrabold tracking-tight text-white sm:text-5xl">
|
||||
Keep your containers current.
|
||||
</h2>
|
||||
<p className="mx-auto mt-5 max-w-xl text-lg text-slate-400">
|
||||
Open source, self-hosted, and free forever. Stop manually checking for image updates
|
||||
and let Updaterup handle it automatically.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap justify-center gap-4">
|
||||
<a
|
||||
href={links.updaterupGitea}
|
||||
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 />
|
||||
Star on Gitea
|
||||
</a>
|
||||
<a
|
||||
href={links.updaterupGitea}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl px-7 py-3.5 text-sm font-bold text-white transition-all hover:brightness-110 hover:scale-105 active:scale-95"
|
||||
style={{ background: "linear-gradient(135deg, #a78bfa, #7c3aed)" }}
|
||||
>
|
||||
Get started →
|
||||
</a>
|
||||
<Link
|
||||
href="/"
|
||||
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"
|
||||
>
|
||||
Also see Backerup
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,12 @@ async function applyMigrations(db: Client): Promise<void> {
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS links (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Runtime-editable link overrides stored in SQLite.
|
||||
* Falls back to the static LINKS defaults for any key without an override.
|
||||
*/
|
||||
import { getDb, ensureReady } from './db'
|
||||
import { LINKS } from './links'
|
||||
|
||||
export type LinkKey = keyof typeof LINKS
|
||||
|
||||
/** Human-readable labels for each link key (used in the admin UI). */
|
||||
export const LINK_META: Record<LinkKey, { label: string; description: string; group: string }> = {
|
||||
gitea: { label: 'Backerup — Gitea repo', description: 'Repository root used in nav, footer, and CTA buttons', group: 'Backerup' },
|
||||
giteaReleases: { label: 'Backerup — Releases', description: 'Releases / changelog page', group: 'Backerup' },
|
||||
giteaIssues: { label: 'Backerup — Issues', description: 'Issue tracker', group: 'Backerup' },
|
||||
giteaDiscussions: { label: 'Backerup — Discussions', description: 'Gitea Discussions forum', group: 'Backerup' },
|
||||
giteaContributing: { label: 'Backerup — Contributing', description: 'CONTRIBUTING.md guide', group: 'Backerup' },
|
||||
docs: { label: 'Backerup — Docs', description: 'Primary documentation URL', group: 'Backerup' },
|
||||
dockerImage: { label: 'Backerup — Docker image', description: 'Full image reference shown in install snippets', group: 'Backerup' },
|
||||
uiPort: { label: 'Backerup — UI port', description: 'Default port the Backerup container exposes', group: 'Backerup' },
|
||||
updaterupGitea: { label: 'Updaterup — Gitea repo', description: 'Repository root used in Updaterup pages', group: 'Updaterup' },
|
||||
updaterupDockerImage: { label: 'Updaterup — Docker image', description: 'Full image reference shown in Updaterup install snippets', group: 'Updaterup' },
|
||||
}
|
||||
|
||||
export interface LinkEntry {
|
||||
key: LinkKey
|
||||
label: string
|
||||
description: string
|
||||
group: string
|
||||
defaultValue: string
|
||||
override: string | null
|
||||
currentValue: string
|
||||
}
|
||||
|
||||
/** Returns merged links: DB overrides take precedence over static defaults. */
|
||||
export async function getLinks(): Promise<typeof LINKS> {
|
||||
await ensureReady()
|
||||
const { rows } = await getDb().execute('SELECT key, value FROM links')
|
||||
const overrides: Partial<Record<string, string>> = {}
|
||||
for (const row of rows) {
|
||||
overrides[row.key as string] = row.value as string
|
||||
}
|
||||
return { ...LINKS, ...overrides } as typeof LINKS
|
||||
}
|
||||
|
||||
/** Returns all link entries with defaults, overrides, and current values — for the admin UI. */
|
||||
export async function getAllLinkEntries(): Promise<LinkEntry[]> {
|
||||
await ensureReady()
|
||||
const { rows } = await getDb().execute('SELECT key, value FROM links')
|
||||
const overrides: Partial<Record<string, string>> = {}
|
||||
for (const row of rows) {
|
||||
overrides[row.key as string] = row.value as string
|
||||
}
|
||||
|
||||
return (Object.keys(LINK_META) as LinkKey[]).map((key) => ({
|
||||
key,
|
||||
...LINK_META[key],
|
||||
defaultValue: LINKS[key],
|
||||
override: overrides[key] ?? null,
|
||||
currentValue: overrides[key] ?? LINKS[key],
|
||||
}))
|
||||
}
|
||||
|
||||
/** Upserts a link override. Pass the static default to effectively reset it, or use resetLink(). */
|
||||
export async function setLink(key: LinkKey, value: string): Promise<void> {
|
||||
await ensureReady()
|
||||
await getDb().execute({
|
||||
sql: `INSERT INTO links (key, value, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
args: [key, value],
|
||||
})
|
||||
}
|
||||
|
||||
/** Removes the DB override for a key, restoring the static default. */
|
||||
export async function resetLink(key: LinkKey): Promise<void> {
|
||||
await ensureReady()
|
||||
await getDb().execute({ sql: 'DELETE FROM links WHERE key = ?', args: [key] })
|
||||
}
|
||||
@@ -26,6 +26,12 @@ export const LINKS = {
|
||||
dockerImage: "gitea.doomlabs.de/kptltd00m/backerup:latest",
|
||||
/** Default UI port the container exposes */
|
||||
uiPort: "8080",
|
||||
|
||||
// ── Updaterup ─────────────────────────────────────────────
|
||||
/** Updaterup repository root */
|
||||
updaterupGitea: "https://gitea.doomlabs.de/KptltD00M/updaterup",
|
||||
/** Updaterup Docker image reference */
|
||||
updaterupDockerImage: "gitea.doomlabs.de/kptltd00m/updaterup:latest",
|
||||
} as const;
|
||||
|
||||
export type Links = typeof LINKS;
|
||||
|
||||
Reference in New Issue
Block a user