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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user