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
2026-05-29 17:04:37 +02:00
|
|
|
'use client';
|
|
|
|
|
|
2026-05-29 17:59:25 +02:00
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
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
2026-05-29 17:04:37 +02:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-29 17:59:25 +02:00
|
|
|
// ── Keyboard shortcut: ⌘+S / Ctrl+S ─────────────────────────
|
|
|
|
|
const handleSaveRef = useCallback(() => {
|
|
|
|
|
if (dirty && !saving) {
|
|
|
|
|
handleSave();
|
|
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [dirty, saving, settings]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
|
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
handleSaveRef();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
|
|
|
}, [handleSaveRef]);
|
|
|
|
|
|
|
|
|
|
// ── Unsaved changes guard ────────────────────────────────────
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
|
|
|
|
if (dirty) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.returnValue = '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
|
|
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
|
|
|
}, [dirty]);
|
|
|
|
|
|
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
2026-05-29 17:04:37 +02:00
|
|
|
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) {
|
2026-05-29 17:20:56 +02:00
|
|
|
const data = await res.json();
|
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
2026-05-29 17:04:37 +02:00
|
|
|
setDirty(false);
|
2026-05-29 17:20:56 +02:00
|
|
|
setSaveMessage(data.message || 'Saved!');
|
|
|
|
|
setTimeout(() => setSaveMessage(''), 5000);
|
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
2026-05-29 17:04:37 +02:00
|
|
|
} 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>
|
|
|
|
|
);
|
|
|
|
|
}
|