release: v0.11.0 (#459)

Ships `dev` as **v0.11.0**. `main` contains nothing that `dev` does not,
so this is a clean sync.

## Headline changes since v0.10.0

- **Journal & Journal Studio** — photo essays at `/journal`,
split-screen block editor with live preview, drafts, per-entry
passwords, cover images
- **First-run setup wizard at `/install`** — configure a fresh
deployment from the browser; credentials are verified against Immich
before anything is written, and the wizard is gated by a one-time token
- **About page editor and toggle** in the admin panel
- **Per-album photo order** independent of Immich, with a drag & drop
editor
- **Custom favicon upload**
- **Experimental portfolio features** — justified grid, `cover` hero,
unlisted subpages, per-album grid overrides, cover focal points,
external nav links
- **Security** — asset tokens length-capped before decoding (#423),
journal author markdown escaped rather than filtered, journal file paths
contained, admin session key derived with scrypt (#432), Next.js 16.3.0
for CVE-2025-13465
- **Fixed** — client proofing honours its own configuration again
(#454); journal photo rendering (#432)

Full detail in [CHANGELOG.md](CHANGELOG.md#0110--2026-08-15).

## Verified

`npx tsc --noEmit` clean · `npx vitest run` 435 tests pass · `npm run
build` succeeds · lint 0 errors · `prettier --check` clean · local
`docker build` of the release tree.

## After merge

Tag `v0.11.0` on `main` and publish the release — that triggers
`docker-publish.yml`, which pushes the image and moves `latest`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ralksta
2026-08-15 09:09:18 +02:00
committed by GitHub
parent 6eda34cc50
commit 2f546f440d
61 changed files with 3953 additions and 1115 deletions

View File

@@ -5,6 +5,9 @@ import { useRouter } from 'next/navigation';
import Link from 'next/link';
import * as Icons from './Icons';
import SaveBar from './SaveBar';
// Direct import from the theme module, not from '@/lib/config': the config
// index pulls in `fs` and cannot be bundled into a client component.
import { DEFAULT_PRESET } from '@/lib/config/theme';
interface Settings {
title?: string;
@@ -73,7 +76,7 @@ interface Settings {
about?: { enabled?: boolean };
}
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'justified'];
const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic', 'cover'];
@@ -234,7 +237,8 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
useEffect(() => {
loadSettings();
if (typeof window !== 'undefined') {
const mode = (document.documentElement.getAttribute('data-theme') as 'dark' | 'light') || 'dark';
const mode =
(document.documentElement.getAttribute('data-theme') as 'dark' | 'light') || 'dark';
setCurrentMode(mode);
}
}, []);
@@ -497,8 +501,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
{activeSection === 'general' && (
<div className="settings-panel">
<div className="settings-section-header">
<h3><Icons.IconGear size={18} /> General Site Settings</h3>
<p className="settings-section-sub">Configure basic site identity, language, and core feature toggles.</p>
<h3>
<Icons.IconGear size={18} /> General Site Settings
</h3>
<p className="settings-section-sub">
Configure basic site identity, language, and core feature toggles.
</p>
</div>
<div className="admin-field">
@@ -534,8 +542,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3><Icons.IconSparkles size={18} /> Portfolio Features &amp; Modules</h3>
<p className="settings-section-sub">Enable or disable optional portfolio modules, privacy analytics, and map widgets.</p>
<h3>
<Icons.IconSparkles size={18} /> Portfolio Features &amp; Modules
</h3>
<p className="settings-section-sub">
Enable or disable optional portfolio modules, privacy analytics, and map widgets.
</p>
</div>
<div className="admin-toggle-cards-grid">
@@ -545,8 +557,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
onClick={() => update('exifOnHover', settings.exifOnHover === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title"><Icons.IconCamera size={16} /> EXIF Data on Hover</span>
<span className="toggle-card-desc">Display camera gear, lens, aperture &amp; shutter speed on hover</span>
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> EXIF Data on Hover
</span>
<span className="toggle-card-desc">
Display camera gear, lens, aperture &amp; shutter speed on hover
</span>
</div>
<div className={`switch-toggle ${settings.exifOnHover !== false ? 'on' : ''}`}>
<span className="switch-slider" />
@@ -559,8 +575,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
onClick={() => update('map', !settings.map)}
>
<div className="toggle-card-info">
<span className="toggle-card-title"><Icons.IconMap size={16} /> Interactive GPS Map</span>
<span className="toggle-card-desc">Enable /map view showing photo locations on a world map</span>
<span className="toggle-card-title">
<Icons.IconMap size={16} /> Interactive GPS Map
</span>
<span className="toggle-card-desc">
Enable /map view showing photo locations on a world map
</span>
</div>
<div className={`switch-toggle ${settings.map === true ? 'on' : ''}`}>
<span className="switch-slider" />
@@ -573,8 +593,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
onClick={() => update('transitions', settings.transitions === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title"><Icons.IconSparkles size={16} /> Smooth Page Transitions</span>
<span className="toggle-card-desc">Enable subtle fade-in animations between page navigation</span>
<span className="toggle-card-title">
<Icons.IconSparkles size={16} /> Smooth Page Transitions
</span>
<span className="toggle-card-desc">
Enable subtle fade-in animations between page navigation
</span>
</div>
<div className={`switch-toggle ${settings.transitions !== false ? 'on' : ''}`}>
<span className="switch-slider" />
@@ -587,8 +611,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
onClick={() => update('scrollToTop', settings.scrollToTop === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title"><Icons.IconArrowUp size={16} /> Scroll-to-Top Button</span>
<span className="toggle-card-desc">Show a floating arrow that returns visitors to the top of long pages</span>
<span className="toggle-card-title">
<Icons.IconArrowUp size={16} /> Scroll-to-Top Button
</span>
<span className="toggle-card-desc">
Show a floating arrow that returns visitors to the top of long pages
</span>
</div>
<div className={`switch-toggle ${settings.scrollToTop !== false ? 'on' : ''}`}>
<span className="switch-slider" />
@@ -601,8 +629,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
onClick={() => update('analytics', settings.analytics === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title"><Icons.IconBarChart size={16} /> Analytics Tracking</span>
<span className="toggle-card-desc">Collect anonymous privacy-friendly visit statistics</span>
<span className="toggle-card-title">
<Icons.IconBarChart size={16} /> Analytics Tracking
</span>
<span className="toggle-card-desc">
Collect anonymous privacy-friendly visit statistics
</span>
</div>
<div className={`switch-toggle ${settings.analytics !== false ? 'on' : ''}`}>
<span className="switch-slider" />
@@ -615,10 +647,17 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
onClick={() => update('proofing.enabled', settings.proofing?.enabled === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title"><Icons.IconHeart size={16} /> Client Proofing &amp; Favorites</span>
<span className="toggle-card-desc">Allow visitors &amp; clients to heart, filter, and export favorite photo selections</span>
<span className="toggle-card-title">
<Icons.IconHeart size={16} /> Client Proofing &amp; Favorites
</span>
<span className="toggle-card-desc">
Allow visitors &amp; clients to heart, filter, and export favorite photo
selections
</span>
</div>
<div className={`switch-toggle ${settings.proofing?.enabled !== false ? 'on' : ''}`}>
<div
className={`switch-toggle ${settings.proofing?.enabled !== false ? 'on' : ''}`}
>
<span className="switch-slider" />
</div>
</button>
@@ -632,7 +671,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> About Page
</span>
<span className="toggle-card-desc">Show a portrait, bio, and gear section on your portfolio</span>
<span className="toggle-card-desc">
Show a portrait, bio, and gear section on your portfolio
</span>
</div>
<div className={`switch-toggle ${settings.about?.enabled !== false ? 'on' : ''}`}>
<span className="switch-slider" />
@@ -655,7 +696,10 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
const form = new FormData();
form.append('file', file);
try {
const res = await fetch('/api/admin/favicon', { method: 'PUT', body: form });
const res = await fetch('/api/admin/favicon', {
method: 'PUT',
body: form,
});
const data = await res.json();
setFaviconMessage(res.ok ? data.message : `Error: ${data.error}`);
} catch {
@@ -689,7 +733,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
{faviconUploading && <div className="admin-spinner" />}
</div>
{faviconMessage && (
<p className={`save-message ${faviconMessage.startsWith('Error') ? 'error' : 'success'}`}>
<p
className={`save-message ${faviconMessage.startsWith('Error') ? 'error' : 'success'}`}
>
{faviconMessage}
</p>
)}
@@ -704,8 +750,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
{activeSection === 'theme' && (
<div className="settings-panel theme-settings-panel">
<div className="settings-section-header">
<h3><Icons.IconPalette size={18} /> Theme Presets &amp; Color Mode</h3>
<p className="settings-section-sub">Choose a typography preset and preview in Light or Dark mode.</p>
<h3>
<Icons.IconPalette size={18} /> Theme Presets &amp; Color Mode
</h3>
<p className="settings-section-sub">
Choose a typography preset and preview in Light or Dark mode.
</p>
</div>
<div className="admin-field">
@@ -724,7 +774,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
frame: 'none' as const,
gap: 4,
};
const isActive = (settings.theme?.preset || 'studio') === p;
const isActive = (settings.theme?.preset || DEFAULT_PRESET) === p;
return (
<button
key={p}
@@ -767,8 +817,17 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="admin-field">
<label>Color Mode</label>
<p style={{ fontSize: '0.78rem', color: 'var(--admin-text-muted)', margin: '0.25rem 0 0.6rem', lineHeight: '1.4' }}>
Presets like <strong>Editorial</strong>, <strong>Minimal</strong> &amp; <strong>Classic</strong> feature warm light/cream backgrounds in Light Mode and charcoal in Dark Mode.
<p
style={{
fontSize: '0.78rem',
color: 'var(--admin-text-muted)',
margin: '0.25rem 0 0.6rem',
lineHeight: '1.4',
}}
>
Presets like <strong>Editorial</strong>, <strong>Minimal</strong> &amp;{' '}
<strong>Classic</strong> feature warm light/cream backgrounds in Light Mode and
charcoal in Dark Mode.
</p>
<div className="segmented-control">
<button
@@ -791,8 +850,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3><Icons.IconTarget size={18} /> Accent Color</h3>
<p className="settings-section-sub">Pick a primary accent color for links, buttons, and highlights.</p>
<h3>
<Icons.IconTarget size={18} /> Accent Color
</h3>
<p className="settings-section-sub">
Pick a primary accent color for links, buttons, and highlights.
</p>
</div>
<div className="admin-field">
@@ -807,7 +870,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
{ hex: '#ffffff', name: 'Monochrome White' },
{ hex: '#000000', name: 'Obsidian Black' },
].map((swatch) => {
const isSelected = (settings.theme?.accent || '#e60012').toLowerCase() === swatch.hex.toLowerCase();
const isSelected =
(settings.theme?.accent || '#e60012').toLowerCase() ===
swatch.hex.toLowerCase();
return (
<button
key={swatch.hex}
@@ -839,8 +904,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3><Icons.IconFrame size={18} /> Photo Frame &amp; Layout</h3>
<p className="settings-section-sub">Customize image presentation borders and hero layouts.</p>
<h3>
<Icons.IconFrame size={18} /> Photo Frame &amp; Layout
</h3>
<p className="settings-section-sub">
Customize image presentation borders and hero layouts.
</p>
</div>
<div className="admin-field">
@@ -849,7 +918,10 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
{PHOTO_FRAMES.map((f) => {
const info = {
none: { label: 'None', desc: 'Flush image with crisp edges' },
passepartout: { label: 'Passepartout', desc: 'Classic gallery matting border' },
passepartout: {
label: 'Passepartout',
desc: 'Classic gallery matting border',
},
shadow: { label: 'Shadow', desc: 'Soft floating drop shadow' },
}[f] || { label: f, desc: '' };
const isActive = (settings.theme?.photoFrame || 'none') === f;
@@ -887,7 +959,10 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
stacked: { label: 'Stacked', desc: 'Title stacked directly over photo' },
typographic: { label: 'Typographic', desc: 'Oversized magazine masthead' },
mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' },
cover: { label: 'Cover (Experimental)', desc: 'Fullscreen splash with a single Enter link' },
cover: {
label: 'Cover (Experimental)',
desc: 'Fullscreen splash with a single Enter link',
},
}[s] || { label: s, desc: '' };
const isActive = (settings.theme?.heroStyle || 'split') === s;
@@ -963,8 +1038,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3><Icons.IconSparkles size={18} /> Finishing Touches</h3>
<p className="settings-section-sub">Enable optional visual effects and indicators.</p>
<h3>
<Icons.IconSparkles size={18} /> Finishing Touches
</h3>
<p className="settings-section-sub">
Enable optional visual effects and indicators.
</p>
</div>
<div className="admin-toggle-cards-grid">
@@ -974,8 +1053,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
onClick={() => update('theme.grain', !settings.theme?.grain)}
>
<div className="toggle-card-info">
<span className="toggle-card-title"><Icons.IconFilm size={16} /> Film Grain Texture</span>
<span className="toggle-card-desc">Adds analog noise overlay across portfolio background</span>
<span className="toggle-card-title">
<Icons.IconFilm size={16} /> Film Grain Texture
</span>
<span className="toggle-card-desc">
Adds analog noise overlay across portfolio background
</span>
</div>
<div className={`switch-toggle ${settings.theme?.grain === true ? 'on' : ''}`}>
<span className="switch-slider" />
@@ -988,10 +1071,16 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
onClick={() => update('theme.headerDot', settings.theme?.headerDot === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title"><Icons.IconTarget size={16} /> Header Accent Dot</span>
<span className="toggle-card-desc">Displays accent dot next to active section header</span>
<span className="toggle-card-title">
<Icons.IconTarget size={16} /> Header Accent Dot
</span>
<span className="toggle-card-desc">
Displays accent dot next to active section header
</span>
</div>
<div className={`switch-toggle ${settings.theme?.headerDot !== false ? 'on' : ''}`}>
<div
className={`switch-toggle ${settings.theme?.headerDot !== false ? 'on' : ''}`}
>
<span className="switch-slider" />
</div>
</button>
@@ -1002,8 +1091,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
{activeSection === 'grid' && (
<div className="settings-panel">
<div className="settings-section-header">
<h3><Icons.IconGrid size={18} /> Grid &amp; Layout Engine</h3>
<p className="settings-section-sub">Configure photography gallery column structures and thumbnail aspect ratios.</p>
<h3>
<Icons.IconGrid size={18} /> Grid &amp; Layout Engine
</h3>
<p className="settings-section-sub">
Configure photography gallery column structures and thumbnail aspect ratios.
</p>
</div>
<div className="admin-field">
@@ -1011,12 +1104,27 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="preset-card-grid">
{LAYOUTS.map((l) => {
const info = {
masonry: { label: 'Masonry', desc: 'Dynamic pinterest-style staggered columns' },
masonry: {
label: 'Masonry',
desc: 'Dynamic pinterest-style staggered columns',
},
uniform: { label: 'Uniform Grid', desc: 'Clean equal aspect ratio grid' },
showcase: { label: 'Showcase', desc: 'Featured hero photos mixed with smaller tiles' },
filmstrip: { label: 'Filmstrip', desc: 'Horizontal scrollable film strip timeline' },
'editorial-flow': { label: 'Editorial Flow', desc: 'Magazine story layout with varying photo sizes' },
justified: { label: 'Justified (Experimental)', desc: 'Equal-height rows that fill the full width' },
showcase: {
label: 'Showcase',
desc: 'Featured hero photos mixed with smaller tiles',
},
filmstrip: {
label: 'Filmstrip',
desc: 'Horizontal scrollable film strip timeline',
},
'editorial-flow': {
label: 'Editorial Flow',
desc: 'Magazine story layout with varying photo sizes',
},
justified: {
label: 'Justified (Experimental)',
desc: 'Equal-height rows that fill the full width',
},
}[l] || { label: l, desc: '' };
const isActive = (settings.grid?.layout || 'masonry') === l;
@@ -1031,38 +1139,65 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className={`mini-layout-demo layout-demo-${l}`}>
{l === 'masonry' && (
<div className="demo-masonry-col-group">
<div className="demo-col"><div className="demo-tile h-high" /><div className="demo-tile h-low" /></div>
<div className="demo-col"><div className="demo-tile h-low" /><div className="demo-tile h-high" /></div>
<div className="demo-col"><div className="demo-tile h-med" /><div className="demo-tile h-med" /></div>
<div className="demo-col">
<div className="demo-tile h-high" />
<div className="demo-tile h-low" />
</div>
<div className="demo-col">
<div className="demo-tile h-low" />
<div className="demo-tile h-high" />
</div>
<div className="demo-col">
<div className="demo-tile h-med" />
<div className="demo-tile h-med" />
</div>
</div>
)}
{l === 'uniform' && (
<div className="demo-uniform-grid">
<div className="demo-tile" /><div className="demo-tile" /><div className="demo-tile" />
<div className="demo-tile" /><div className="demo-tile" /><div className="demo-tile" />
<div className="demo-tile" />
<div className="demo-tile" />
<div className="demo-tile" />
<div className="demo-tile" />
<div className="demo-tile" />
<div className="demo-tile" />
</div>
)}
{l === 'showcase' && (
<div className="demo-showcase-grid">
<div className="demo-tile demo-hero" />
<div className="demo-col"><div className="demo-tile" /><div className="demo-tile" /></div>
<div className="demo-col">
<div className="demo-tile" />
<div className="demo-tile" />
</div>
</div>
)}
{l === 'filmstrip' && (
<div className="demo-filmstrip-row">
<div className="demo-tile strip" /><div className="demo-tile strip" /><div className="demo-tile strip" />
<div className="demo-tile strip" />
<div className="demo-tile strip" />
<div className="demo-tile strip" />
</div>
)}
{l === 'editorial-flow' && (
<div className="demo-editorial-flow">
<div className="demo-tile wide" />
<div className="demo-row"><div className="demo-tile" /><div className="demo-tile" /></div>
<div className="demo-row">
<div className="demo-tile" />
<div className="demo-tile" />
</div>
</div>
)}
{l === 'justified' && (
<div className="demo-editorial-flow">
<div className="demo-row"><div className="demo-tile wide" /><div className="demo-tile" /></div>
<div className="demo-row"><div className="demo-tile" /><div className="demo-tile wide" /></div>
<div className="demo-row">
<div className="demo-tile wide" />
<div className="demo-tile" />
</div>
<div className="demo-row">
<div className="demo-tile" />
<div className="demo-tile wide" />
</div>
</div>
)}
</div>
@@ -1086,7 +1221,10 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
'3/2': { label: 'Landscape (3:2)', desc: 'Standard 35mm DSLR landscape' },
'2/3': { label: 'Portrait (2:3)', desc: 'Vertical portrait orientation' },
'16/9': { label: 'Cinema (16:9)', desc: 'Widescreen 16:9 cinematic ratio' },
auto: { label: 'Original Auto', desc: 'Uncropped original image proportions' },
auto: {
label: 'Original Auto',
desc: 'Uncropped original image proportions',
},
}[r] || { label: r, desc: '' };
const isActive = (settings.grid?.aspectRatio || '1') === r;
@@ -1115,7 +1253,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3><Icons.IconColumns size={18} /> Spacing &amp; Columns</h3>
<h3>
<Icons.IconColumns size={18} /> Spacing &amp; Columns
</h3>
<p className="settings-section-sub">Adjust column counts and grid gap spacing.</p>
</div>
@@ -1147,8 +1287,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
{activeSection === 'footer' && (
<div className="settings-panel">
<div className="settings-section-header">
<h3><Icons.IconLink size={18} /> Footer &amp; Social Links</h3>
<p className="settings-section-sub">Display branding, Instagram, email and website links in portfolio footer.</p>
<h3>
<Icons.IconLink size={18} /> Footer &amp; Social Links
</h3>
<p className="settings-section-sub">
Display branding, Instagram, email and website links in portfolio footer.
</p>
</div>
<div className="admin-field">
@@ -1187,8 +1331,13 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
</div>
<div className="settings-section-header" style={{ marginTop: '2rem' }}>
<h3><Icons.IconLink size={18} /> Header Navigation Links (Experimental)</h3>
<p className="settings-section-sub">External links shown after your pages in the header menu. Only http(s) URLs are allowed; they open in a new tab.</p>
<h3>
<Icons.IconLink size={18} /> Header Navigation Links (Experimental)
</h3>
<p className="settings-section-sub">
External links shown after your pages in the header menu. Only http(s) URLs are
allowed; they open in a new tab.
</p>
</div>
{(settings.navLinks || []).map((link, i) => (
@@ -1234,7 +1383,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<button
type="button"
className="admin-btn admin-btn-sm"
onClick={() => update('navLinks', [...(settings.navLinks || []), { label: '', url: '' }])}
onClick={() =>
update('navLinks', [...(settings.navLinks || []), { label: '', url: '' }])
}
>
+ Add external link
</button>
@@ -1244,8 +1395,13 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
{activeSection === 'legal' && (
<div className="settings-panel">
<div className="settings-section-header">
<h3><Icons.IconScale size={18} /> Legal Notice &amp; Impressum</h3>
<p className="settings-section-sub">Configure required legal disclosure page for EU / German Telemediengesetz compliance.</p>
<h3>
<Icons.IconScale size={18} /> Legal Notice &amp; Impressum
</h3>
<p className="settings-section-sub">
Configure required legal disclosure page for EU / German Telemediengesetz
compliance.
</p>
</div>
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
@@ -1255,8 +1411,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
onClick={() => update('legal.enabled', !settings.legal?.enabled)}
>
<div className="toggle-card-info">
<span className="toggle-card-title"><Icons.IconFileText size={16} /> Enable Impressum Page (/impressum)</span>
<span className="toggle-card-desc">Automatically generates and links /impressum in footer</span>
<span className="toggle-card-title">
<Icons.IconFileText size={16} /> Enable Impressum Page (/impressum)
</span>
<span className="toggle-card-desc">
Automatically generates and links /impressum in footer
</span>
</div>
<div className={`switch-toggle ${settings.legal?.enabled === true ? 'on' : ''}`}>
<span className="switch-slider" />
@@ -1337,14 +1497,20 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
{activeSection === 'seo' && (
<div className="settings-panel">
<div className="settings-section-header">
<h3><Icons.IconSearch size={18} /> Search Engine Optimization (SEO)</h3>
<p className="settings-section-sub">Customize search engine metadata, OpenGraph tags, and indexing rules.</p>
<h3>
<Icons.IconSearch size={18} /> Search Engine Optimization (SEO)
</h3>
<p className="settings-section-sub">
Customize search engine metadata, OpenGraph tags, and indexing rules.
</p>
</div>
{/* Live Google Search Result Snippet Card */}
<div className="google-snippet-preview">
<div className="google-snippet-header">
<span><Icons.IconGlobe size={14} /> Google Search Result Preview</span>
<span>
<Icons.IconGlobe size={14} /> Google Search Result Preview
</span>
</div>
<div className="google-snippet-card">
<div className="google-snippet-url">
@@ -1354,7 +1520,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
{settings.seo?.title || settings.title || 'My Photography Portfolio'}
</div>
<div className="google-snippet-desc">
{settings.seo?.description || settings.subtitle || 'A curated selection of photography work.'}
{settings.seo?.description ||
settings.subtitle ||
'A curated selection of photography work.'}
</div>
</div>
</div>
@@ -1376,7 +1544,8 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
placeholder={`%s | ${settings.seo?.title || settings.title || 'My Portfolio'}`}
/>
<p style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '4px' }}>
Template for subpages &amp; albums. Use <code>%s</code> as placeholder for the page title.
Template for subpages &amp; albums. Use <code>%s</code> as placeholder for the
page title.
<br />
<strong>Preview:</strong>{' '}
{(
@@ -1399,8 +1568,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3><Icons.IconGlobe size={18} /> Search Crawler Directives</h3>
<p className="settings-section-sub">Control how Googlebot and other web crawlers index your site.</p>
<h3>
<Icons.IconGlobe size={18} /> Search Crawler Directives
</h3>
<p className="settings-section-sub">
Control how Googlebot and other web crawlers index your site.
</p>
</div>
<div className="admin-toggle-cards-grid">
@@ -1410,8 +1583,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
onClick={() => update('seo.noIndex', !settings.seo?.noIndex)}
>
<div className="toggle-card-info">
<span className="toggle-card-title"><Icons.IconBan size={16} /> noindex (Hide from Google)</span>
<span className="toggle-card-desc">Instructs search engines NOT to index this site in search results</span>
<span className="toggle-card-title">
<Icons.IconBan size={16} /> noindex (Hide from Google)
</span>
<span className="toggle-card-desc">
Instructs search engines NOT to index this site in search results
</span>
</div>
<div className={`switch-toggle ${settings.seo?.noIndex === true ? 'on' : ''}`}>
<span className="switch-slider" />
@@ -1424,8 +1601,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
onClick={() => update('seo.noFollow', !settings.seo?.noFollow)}
>
<div className="toggle-card-info">
<span className="toggle-card-title"><Icons.IconLink size={16} /> nofollow (Block Link Following)</span>
<span className="toggle-card-desc">Instructs search engine crawlers not to follow outgoing links</span>
<span className="toggle-card-title">
<Icons.IconLink size={16} /> nofollow (Block Link Following)
</span>
<span className="toggle-card-desc">
Instructs search engine crawlers not to follow outgoing links
</span>
</div>
<div className={`switch-toggle ${settings.seo?.noFollow === true ? 'on' : ''}`}>
<span className="switch-slider" />
@@ -1438,21 +1619,33 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
{activeSection === 'security' && (
<div className="settings-panel">
<div className="settings-section-header">
<h3><Icons.IconShieldCheck size={18} /> Asset Protection &amp; Watermark</h3>
<p className="settings-section-sub">Configure image protection rules, right-click prevention, and watermark overlay.</p>
<h3>
<Icons.IconShieldCheck size={18} /> Asset Protection &amp; Watermark
</h3>
<p className="settings-section-sub">
Configure image protection rules, right-click prevention, and watermark overlay.
</p>
</div>
<div className="admin-toggle-cards-grid">
<button
type="button"
className={`admin-toggle-card ${settings.protection?.disableRightClick === true ? 'active' : ''}`}
onClick={() => update('protection.disableRightClick', !settings.protection?.disableRightClick)}
onClick={() =>
update('protection.disableRightClick', !settings.protection?.disableRightClick)
}
>
<div className="toggle-card-info">
<span className="toggle-card-title"><Icons.IconLock size={16} /> Disable Right-Click Menu</span>
<span className="toggle-card-desc">Prevents context menu on portfolio images to hinder unauthorized downloads</span>
<span className="toggle-card-title">
<Icons.IconLock size={16} /> Disable Right-Click Menu
</span>
<span className="toggle-card-desc">
Prevents context menu on portfolio images to hinder unauthorized downloads
</span>
</div>
<div className={`switch-toggle ${settings.protection?.disableRightClick === true ? 'on' : ''}`}>
<div
className={`switch-toggle ${settings.protection?.disableRightClick === true ? 'on' : ''}`}
>
<span className="switch-slider" />
</div>
</button>
@@ -1460,13 +1653,21 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<button
type="button"
className={`admin-toggle-card ${settings.protection?.disableImageDrag === true ? 'active' : ''}`}
onClick={() => update('protection.disableImageDrag', !settings.protection?.disableImageDrag)}
onClick={() =>
update('protection.disableImageDrag', !settings.protection?.disableImageDrag)
}
>
<div className="toggle-card-info">
<span className="toggle-card-title"><Icons.IconBan size={16} /> Disable Image Dragging</span>
<span className="toggle-card-desc">Prevents visitors from dragging images off the portfolio page</span>
<span className="toggle-card-title">
<Icons.IconBan size={16} /> Disable Image Dragging
</span>
<span className="toggle-card-desc">
Prevents visitors from dragging images off the portfolio page
</span>
</div>
<div className={`switch-toggle ${settings.protection?.disableImageDrag === true ? 'on' : ''}`}>
<div
className={`switch-toggle ${settings.protection?.disableImageDrag === true ? 'on' : ''}`}
>
<span className="switch-slider" />
</div>
</button>
@@ -1475,8 +1676,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3><Icons.IconSparkles size={18} /> Dynamic Watermark Overlay</h3>
<p className="settings-section-sub">Overlay copyright branding text on Lightbox images.</p>
<h3>
<Icons.IconSparkles size={18} /> Dynamic Watermark Overlay
</h3>
<p className="settings-section-sub">
Overlay copyright branding text on Lightbox images.
</p>
</div>
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
@@ -1486,10 +1691,16 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
onClick={() => update('watermark.enabled', !settings.watermark?.enabled)}
>
<div className="toggle-card-info">
<span className="toggle-card-title"><Icons.IconFrame size={16} /> Enable Watermark</span>
<span className="toggle-card-desc">Overlay copyright text on portfolio image views</span>
<span className="toggle-card-title">
<Icons.IconFrame size={16} /> Enable Watermark
</span>
<span className="toggle-card-desc">
Overlay copyright text on portfolio image views
</span>
</div>
<div className={`switch-toggle ${settings.watermark?.enabled === true ? 'on' : ''}`}>
<div
className={`switch-toggle ${settings.watermark?.enabled === true ? 'on' : ''}`}
>
<span className="switch-slider" />
</div>
</button>
@@ -1519,7 +1730,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
</select>
</div>
<div className="admin-field">
<label>Opacity ({Math.round((settings.watermark?.opacity ?? 0.3) * 100)}%)</label>
<label>
Opacity ({Math.round((settings.watermark?.opacity ?? 0.3) * 100)}%)
</label>
<input
type="range"
min="0.1"