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:
Anders Böttcher
2026-05-13 10:53:21 +02:00
parent 33e339bde6
commit 984b282a95
16 changed files with 1342 additions and 51 deletions
+30
View File
@@ -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>
)
}
+256
View File
@@ -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>
)
}
+49
View File
@@ -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'
+20
View File
@@ -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} />
}
+1 -23
View File
@@ -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} />
}