feat: add visual admin panel with page builder and settings editor
- Add /admin route with password-protected visual page builder
- Page Builder: manage hero images, standalone albums, subpages, and sections
- Album Picker: browse all shared Immich albums with search and filtering
- Settings Editor: theme, grid, footer, legal, SEO configuration
- YAML service with atomic writes and automatic backups (content/.backups/)
- HMAC-signed admin sessions (24h expiry, HttpOnly cookies)
- API endpoints: /api/admin/{auth,gallery,settings,albums,reload}
- Enable with ADMIN_PASSWORD env var; disabled by default
- invalidateConfigCache() export for hot-reload after saves
This commit is contained in:
559
app/admin/components/SettingsEditor.tsx
Normal file
559
app/admin/components/SettingsEditor.tsx
Normal file
@@ -0,0 +1,559 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Settings {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
lang?: string;
|
||||
exifOnHover?: boolean;
|
||||
map?: boolean;
|
||||
transitions?: boolean;
|
||||
theme?: {
|
||||
preset?: string;
|
||||
accent?: string;
|
||||
photoFrame?: string;
|
||||
grain?: boolean;
|
||||
headerDot?: boolean;
|
||||
heroStyle?: string;
|
||||
};
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
footer?: {
|
||||
name?: string;
|
||||
instagram?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
};
|
||||
legal?: {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
address?: string;
|
||||
zipCity?: string;
|
||||
country?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
taxId?: string;
|
||||
vatId?: string;
|
||||
extraInfo?: string;
|
||||
};
|
||||
seo?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
noIndex?: boolean;
|
||||
noFollow?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const PRESETS = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
|
||||
const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
|
||||
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
|
||||
const ASPECT_RATIOS = ['1', '3/2', '2/3', '16/9', 'auto'];
|
||||
|
||||
export default function SettingsEditor() {
|
||||
const [settings, setSettings] = useState<Settings>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
const [activeSection, setActiveSection] = useState('general');
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
async function loadSettings() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings');
|
||||
if (res.ok) {
|
||||
const { settings: data } = await res.json();
|
||||
setSettings(data || {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function update(path: string, value: unknown) {
|
||||
setSettings((s) => {
|
||||
const copy = JSON.parse(JSON.stringify(s));
|
||||
const parts = path.split('.');
|
||||
let obj = copy;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!obj[parts[i]]) obj[parts[i]] = {};
|
||||
obj = obj[parts[i]];
|
||||
}
|
||||
const key = parts[parts.length - 1];
|
||||
if (value === '' || value === undefined) {
|
||||
delete obj[key];
|
||||
} else {
|
||||
obj[key] = value;
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
setDirty(true);
|
||||
setSaveMessage('');
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setSaveMessage('');
|
||||
|
||||
// Clean up empty objects
|
||||
const cleaned = JSON.parse(JSON.stringify(settings));
|
||||
for (const key of Object.keys(cleaned)) {
|
||||
if (typeof cleaned[key] === 'object' && Object.keys(cleaned[key]).length === 0) {
|
||||
delete cleaned[key];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: cleaned }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setDirty(false);
|
||||
setSaveMessage('Saved!');
|
||||
setTimeout(() => setSaveMessage(''), 3000);
|
||||
} else {
|
||||
const err = await res.json();
|
||||
setSaveMessage(`Error: ${err.error}`);
|
||||
}
|
||||
} catch {
|
||||
setSaveMessage('Error: Failed to save');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'theme', label: 'Theme' },
|
||||
{ id: 'grid', label: 'Grid' },
|
||||
{ id: 'footer', label: 'Footer' },
|
||||
{ id: 'legal', label: 'Legal' },
|
||||
{ id: 'seo', label: 'SEO' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-editor">
|
||||
{/* Save Bar */}
|
||||
<div className={`save-bar ${dirty ? 'dirty' : ''}`}>
|
||||
<div className="save-bar-left">
|
||||
{dirty && <span className="unsaved-badge">Unsaved changes</span>}
|
||||
{saveMessage && (
|
||||
<span
|
||||
className={`save-message ${saveMessage.startsWith('Error') ? 'error' : 'success'}`}
|
||||
>
|
||||
{saveMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="admin-btn admin-btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-layout">
|
||||
{/* Sidebar */}
|
||||
<nav className="settings-nav">
|
||||
{sections.map((sec) => (
|
||||
<button
|
||||
key={sec.id}
|
||||
className={`settings-nav-item ${activeSection === sec.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveSection(sec.id)}
|
||||
>
|
||||
{sec.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<div className="settings-content">
|
||||
{activeSection === 'general' && (
|
||||
<div className="settings-panel">
|
||||
<h3>General</h3>
|
||||
<div className="admin-field">
|
||||
<label>Site Title</label>
|
||||
<input
|
||||
value={settings.title || ''}
|
||||
onChange={(e) => update('title', e.target.value)}
|
||||
placeholder="My Portfolio"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Subtitle</label>
|
||||
<input
|
||||
value={settings.subtitle || ''}
|
||||
onChange={(e) => update('subtitle', e.target.value)}
|
||||
placeholder="A visual journal"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Language</label>
|
||||
<select
|
||||
value={settings.lang || 'en'}
|
||||
onChange={(e) => update('lang', e.target.value)}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="ja">日本語</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-field-checks">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.exifOnHover !== false}
|
||||
onChange={(e) => update('exifOnHover', e.target.checked)}
|
||||
/>
|
||||
EXIF on Hover
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.map === true}
|
||||
onChange={(e) => update('map', e.target.checked)}
|
||||
/>
|
||||
Map enabled
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.transitions !== false}
|
||||
onChange={(e) => update('transitions', e.target.checked)}
|
||||
/>
|
||||
Page Transitions
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'theme' && (
|
||||
<div className="settings-panel">
|
||||
<h3>Theme</h3>
|
||||
<div className="admin-field">
|
||||
<label>Preset</label>
|
||||
<div className="preset-grid">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`preset-btn ${(settings.theme?.preset || 'studio') === p ? 'active' : ''}`}
|
||||
onClick={() => update('theme.preset', p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Accent Color</label>
|
||||
<div className="color-field">
|
||||
<input
|
||||
type="color"
|
||||
value={settings.theme?.accent || '#e60012'}
|
||||
onChange={(e) => update('theme.accent', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.theme?.accent || ''}
|
||||
onChange={(e) => update('theme.accent', e.target.value)}
|
||||
placeholder="#e60012"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Photo Frame</label>
|
||||
<div className="radio-group">
|
||||
{PHOTO_FRAMES.map((f) => (
|
||||
<label key={f}>
|
||||
<input
|
||||
type="radio"
|
||||
name="photoFrame"
|
||||
checked={(settings.theme?.photoFrame || 'none') === f}
|
||||
onChange={() => update('theme.photoFrame', f)}
|
||||
/>
|
||||
{f}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Hero Style</label>
|
||||
<select
|
||||
value={settings.theme?.heroStyle || 'split'}
|
||||
onChange={(e) => update('theme.heroStyle', e.target.value)}
|
||||
>
|
||||
{HERO_STYLES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-field-checks">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.theme?.grain === true}
|
||||
onChange={(e) => update('theme.grain', e.target.checked)}
|
||||
/>
|
||||
Film Grain
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.theme?.headerDot !== false}
|
||||
onChange={(e) => update('theme.headerDot', e.target.checked)}
|
||||
/>
|
||||
Header Dot
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'grid' && (
|
||||
<div className="settings-panel">
|
||||
<h3>Grid Layout</h3>
|
||||
<div className="admin-field">
|
||||
<label>Layout</label>
|
||||
<div className="preset-grid">
|
||||
{LAYOUTS.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
className={`preset-btn ${(settings.grid?.layout || 'masonry') === l ? 'active' : ''}`}
|
||||
onClick={() => update('grid.layout', l)}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field-row">
|
||||
<div className="admin-field">
|
||||
<label>Columns</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={6}
|
||||
value={settings.grid?.columns ?? 3}
|
||||
onChange={(e) => update('grid.columns', parseInt(e.target.value) || 3)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Gap (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={48}
|
||||
value={settings.grid?.gap ?? 12}
|
||||
onChange={(e) => update('grid.gap', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Aspect Ratio</label>
|
||||
<div className="preset-grid">
|
||||
{ASPECT_RATIOS.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
className={`preset-btn ${(settings.grid?.aspectRatio || '1') === r ? 'active' : ''}`}
|
||||
onClick={() => update('grid.aspectRatio', r)}
|
||||
>
|
||||
{r === '1' ? 'Square' : r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'footer' && (
|
||||
<div className="settings-panel">
|
||||
<h3>Footer</h3>
|
||||
<div className="admin-field">
|
||||
<label>Name</label>
|
||||
<input
|
||||
value={settings.footer?.name || ''}
|
||||
onChange={(e) => update('footer.name', e.target.value)}
|
||||
placeholder="My Photography"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Instagram URL</label>
|
||||
<input
|
||||
value={settings.footer?.instagram || ''}
|
||||
onChange={(e) => update('footer.instagram', e.target.value)}
|
||||
placeholder="https://instagram.com/your-handle"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Email</label>
|
||||
<input
|
||||
value={settings.footer?.email || ''}
|
||||
onChange={(e) => update('footer.email', e.target.value)}
|
||||
placeholder="hello@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Website</label>
|
||||
<input
|
||||
value={settings.footer?.website || ''}
|
||||
onChange={(e) => update('footer.website', e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'legal' && (
|
||||
<div className="settings-panel">
|
||||
<h3>Legal / Impressum</h3>
|
||||
<div className="admin-field-checks">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.legal?.enabled === true}
|
||||
onChange={(e) => update('legal.enabled', e.target.checked)}
|
||||
/>
|
||||
Enable Impressum page
|
||||
</label>
|
||||
</div>
|
||||
{settings.legal?.enabled && (
|
||||
<>
|
||||
<div className="admin-field">
|
||||
<label>Full Name</label>
|
||||
<input
|
||||
value={settings.legal?.name || ''}
|
||||
onChange={(e) => update('legal.name', e.target.value)}
|
||||
placeholder="Max Mustermann"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Address</label>
|
||||
<input
|
||||
value={settings.legal?.address || ''}
|
||||
onChange={(e) => update('legal.address', e.target.value)}
|
||||
placeholder="Musterstraße 1"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field-row">
|
||||
<div className="admin-field">
|
||||
<label>ZIP & City</label>
|
||||
<input
|
||||
value={settings.legal?.zipCity || ''}
|
||||
onChange={(e) => update('legal.zipCity', e.target.value)}
|
||||
placeholder="12345 Berlin"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Country</label>
|
||||
<input
|
||||
value={settings.legal?.country || ''}
|
||||
onChange={(e) => update('legal.country', e.target.value)}
|
||||
placeholder="Germany"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field-row">
|
||||
<div className="admin-field">
|
||||
<label>Email</label>
|
||||
<input
|
||||
value={settings.legal?.email || ''}
|
||||
onChange={(e) => update('legal.email', e.target.value)}
|
||||
placeholder="legal@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Phone</label>
|
||||
<input
|
||||
value={settings.legal?.phone || ''}
|
||||
onChange={(e) => update('legal.phone', e.target.value)}
|
||||
placeholder="+49 123 456789"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Extra Info</label>
|
||||
<textarea
|
||||
value={settings.legal?.extraInfo || ''}
|
||||
onChange={(e) => update('legal.extraInfo', e.target.value)}
|
||||
placeholder="Verantwortlich für den Inhalt..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'seo' && (
|
||||
<div className="settings-panel">
|
||||
<h3>SEO & Metadata</h3>
|
||||
<div className="admin-field">
|
||||
<label>SEO Title</label>
|
||||
<input
|
||||
value={settings.seo?.title || ''}
|
||||
onChange={(e) => update('seo.title', e.target.value)}
|
||||
placeholder="Overrides site title for search engines"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>SEO Description</label>
|
||||
<textarea
|
||||
value={settings.seo?.description || ''}
|
||||
onChange={(e) => update('seo.description', e.target.value)}
|
||||
placeholder="A curated selection of photography work."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field-checks">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.seo?.noIndex === true}
|
||||
onChange={(e) => update('seo.noIndex', e.target.checked)}
|
||||
/>
|
||||
noindex (hide from search engines)
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.seo?.noFollow === true}
|
||||
onChange={(e) => update('seo.noFollow', e.target.checked)}
|
||||
/>
|
||||
nofollow (don't follow links)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user