'use client';
import { useState, useEffect, useCallback } from 'react';
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';
import {
PHOTO_GRID_COLUMNS_MAX,
PHOTO_GRID_COLUMNS_MIN,
PHOTO_GRID_GAP_MAX,
resolveExifDisplay,
resolveWatermarkOpacity,
} from '@/lib/config/schema';
import { SUPPORTED_LOCALES } from '@/lib/i18n';
interface Settings {
title?: string;
subtitle?: string;
/** Absolute site URL for sitemap, feed and structured data (#472). */
url?: string;
lang?: string;
sitePassword?: string;
mode?: 'light' | 'dark' | 'auto';
exifOnHover?: boolean;
exif?: {
camera?: boolean;
settings?: boolean;
location?: boolean;
caption?: boolean;
};
map?: boolean;
transitions?: boolean;
scrollToTop?: boolean;
analytics?: 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;
};
/** EXPERIMENTAL: external links appended to the header navigation */
navLinks?: Array<{ label?: string; url?: 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;
titleTemplate?: string;
noIndex?: boolean;
noFollow?: boolean;
};
protection?: {
disableRightClick?: boolean;
disableImageDrag?: boolean;
};
proofing?: {
enabled?: boolean;
allowMailto?: boolean;
};
watermark?: {
enabled?: boolean;
text?: string;
opacity?: number;
position?: 'bottom-right' | 'bottom-left' | 'center';
};
about?: { enabled?: boolean };
}
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'];
const ASPECT_RATIOS = ['1', '3/2', '2/3', '16/9', 'auto'];
/**
* Card metadata for the theme picker. `font`, `radius` and `frame` mirror the
* real preset definitions in lib/config/theme.ts so the mini mockups show what
* the preset actually does; `gap` is the visual density of its gallery grid.
*/
const THEME_INFO: Record<
string,
{
desc: string;
label: string;
accent: string;
bg: string;
tile: string;
font: string;
type: 'serif' | 'sans' | 'mono';
radius: number;
frame: 'none' | 'passepartout' | 'shadow';
gap: number;
}
> = {
studio: {
label: 'Studio',
desc: 'Clean, high-contrast grid with sans-serif type.',
bg: '#141414',
tile: '#242424',
accent: '#e60012',
font: 'Playfair Display',
type: 'serif',
radius: 0,
frame: 'passepartout',
gap: 4,
},
'studio-modern': {
label: 'Studio Modern',
desc: 'Leica precision: Archivo grotesque, mono EXIF, red signal accents.',
bg: '#121212',
tile: '#191919',
accent: '#e60012',
font: 'Archivo',
type: 'sans',
radius: 0,
frame: 'none',
gap: 3,
},
minimal: {
label: 'Minimal',
desc: 'Pure raw layouts with tiny gaps and high whitespace.',
bg: '#ffffff',
tile: '#f3f3f3',
accent: '#111111',
font: 'Geist',
type: 'sans',
radius: 0,
frame: 'none',
gap: 2,
},
editorial: {
label: 'Editorial',
desc: 'Warm backgrounds, elegant serifs and large headers.',
bg: '#fbf9f4',
tile: '#e5dfd4',
accent: '#b89053',
font: 'Bodoni Moda',
type: 'serif',
radius: 0,
frame: 'shadow',
gap: 8,
},
classic: {
label: 'Classic',
desc: 'Soft traditional photographer portfolio with shadows.',
bg: '#f7f7f7',
tile: '#ffffff',
accent: '#444444',
font: 'Cinzel',
type: 'serif',
radius: 12,
frame: 'passepartout',
gap: 7,
},
noir: {
label: 'Noir',
desc: 'High drama absolute pitch black, stark high-fashion look.',
bg: '#000000',
tile: '#151515',
accent: '#ffffff',
font: 'Libre Baskerville',
type: 'serif',
radius: 0,
frame: 'passepartout',
gap: 6,
},
monograph: {
label: 'Monograph',
desc: 'Typewriter monospace font, grid borders and document feel.',
bg: '#f4f4f6',
tile: '#ffffff',
accent: '#555555',
font: 'Instrument Serif',
type: 'mono',
radius: 0,
frame: 'none',
gap: 5,
},
};
const SETTINGS_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' },
{ id: 'security', label: 'Security & Protection' },
{ id: 'about', label: 'About' },
];
interface SettingsEditorProps {
/** Sub-section to show, taken from the /admin/settings/[section] route. */
section?: string;
}
/**
* One labelled block of related switches.
*
* The features panel was a single flat grid of identical cards, so nothing said
* which switches belonged together — and a switch that publishes private notes
* looked exactly like one that controls a fade (#510).
*/
function FeatureGroup({
icon,
title,
description,
chip,
children,
}: {
icon: React.ReactNode;
title: string;
description: string;
/** Short marker for a group that needs its own weight, e.g. what visitors see. */
chip?: string;
children: React.ReactNode;
}) {
return (
{icon}
{title}
{chip && {chip}}
{description}
{children}
);
}
/**
* A switch inside a group panel. Deliberately lighter than a toggle card: these
* are settings *of* the panel they sit in, and rendering them as peers of the
* top-level cards is what hid the structure in the first place.
*/
function SettingRow({
title,
description,
checked,
onToggle,
disabled,
hint,
indented,
}: {
title: string;
description: string;
checked: boolean;
onToggle: () => void;
/** A row whose parent switch is off — shown, but inert and explained. */
disabled?: boolean;
hint?: string;
indented?: boolean;
}) {
return (
);
}
export default function SettingsEditor({ section }: SettingsEditorProps) {
const router = useRouter();
// An unknown section in the URL falls back to General instead of an empty panel.
const activeSection = SETTINGS_SECTIONS.some((sec) => sec.id === section)
? (section as string)
: 'general';
const [settings, setSettings] = useState({});
/** Resolved site URL and its origin, so the panel can name SITE_URL (#472). */
const [siteUrlInfo, setSiteUrlInfo] = useState<{
effective: string | null;
source: 'env' | 'settings' | 'none';
} | null>(null);
const [loading, setLoading] = useState(true);
// Collapsed by default: the four metadata switches are a detail of one
// decision, and showing them permanently is what made the section a wall (#510).
const [metadataOpen, setMetadataOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
const [currentMode, setCurrentMode] = useState<'dark' | 'light'>('dark');
const [faviconUploading, setFaviconUploading] = useState(false);
const [faviconMessage, setFaviconMessage] = useState('');
// About content state (independent of main settings save)
const [aboutMeta, setAboutMeta] = useState<{
portrait?: string;
name?: string;
location?: string;
gear?: string[];
}>({});
const [aboutBody, setAboutBody] = useState('');
const [aboutLoading, setAboutLoading] = useState(false);
const [aboutSaving, setAboutSaving] = useState(false);
const [aboutDirty, setAboutDirty] = useState(false);
const [aboutMessage, setAboutMessage] = useState('');
const [aboutGearText, setAboutGearText] = useState('');
useEffect(() => {
loadSettings();
if (typeof window !== 'undefined') {
const mode =
(document.documentElement.getAttribute('data-theme') as 'dark' | 'light') || 'dark';
setCurrentMode(mode);
}
}, []);
// Sync picked preset & accent color to document element immediately for live feedback
useEffect(() => {
if (typeof document !== 'undefined') {
if (settings.theme?.preset) {
document.documentElement.setAttribute('data-preset', settings.theme.preset);
}
if (settings.theme?.accent) {
document.documentElement.style.setProperty('--accent', settings.theme.accent);
document.documentElement.style.setProperty('--admin-accent', settings.theme.accent);
}
}
}, [settings.theme?.preset, settings.theme?.accent]);
// The About panel has its own route, so load its content when that section opens
useEffect(() => {
if (activeSection === 'about') loadAboutContent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSection]);
const toggleMode = (mode: 'dark' | 'light') => {
setCurrentMode(mode);
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', mode);
localStorage.setItem('theme', mode);
}
};
// ── Keyboard shortcut: ⌘+S / Ctrl+S ─────────────────────────
const handleSaveRef = useCallback(() => {
if (activeSection === 'about') {
if (aboutDirty && !aboutSaving) saveAboutContent();
return;
}
if (dirty && !saving) {
handleSave();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
dirty,
saving,
settings,
activeSection,
aboutDirty,
aboutSaving,
aboutMeta,
aboutBody,
aboutGearText,
]);
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]);
async function loadSettings() {
setLoading(true);
try {
const res = await fetch('/api/admin/settings');
if (res.ok) {
const { settings: data, siteUrl } = await res.json();
setSettings(data || {});
setSiteUrlInfo(siteUrl ?? null);
}
} catch (err) {
console.error('Failed to load settings:', err);
} finally {
setLoading(false);
}
}
/** Write several paths in one state update — used by the metadata master switch. */
function updateMany(entries: Record) {
setSettings((s) => {
const copy = JSON.parse(JSON.stringify(s));
for (const [path, value] of Object.entries(entries)) {
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]];
}
obj[parts[parts.length - 1]] = value;
}
return copy;
});
setDirty(true);
setSaveMessage('');
}
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) {
const data = await res.json();
setDirty(false);
setSaveMessage(data.message || 'Saved!');
router.refresh();
setTimeout(() => setSaveMessage(''), 5000);
} else {
const err = await res.json();
setSaveMessage(`Error: ${err.error}`);
}
} catch {
setSaveMessage('Error: Failed to save');
} finally {
setSaving(false);
}
}
async function loadAboutContent() {
setAboutLoading(true);
try {
const res = await fetch('/api/admin/about');
if (res.ok) {
const data = await res.json();
setAboutMeta(data.meta || {});
setAboutBody(data.body || '');
setAboutGearText(data.meta?.gear?.join('\n') || '');
}
} catch (err) {
console.error('Failed to load about content:', err);
} finally {
setAboutLoading(false);
}
}
async function saveAboutContent() {
setAboutSaving(true);
setAboutMessage('');
const cleanedMeta = { ...aboutMeta };
for (const [k, v] of Object.entries(cleanedMeta)) {
if (v === '' || v === undefined) delete cleanedMeta[k as keyof typeof cleanedMeta];
}
const gearLines = aboutGearText
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
if (gearLines.length > 0) {
cleanedMeta.gear = gearLines;
} else {
delete cleanedMeta.gear;
}
try {
const res = await fetch('/api/admin/about', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ meta: cleanedMeta, body: aboutBody }),
});
if (res.ok) {
const data = await res.json();
setAboutDirty(false);
setAboutMessage(data.message || 'Saved!');
setTimeout(() => setAboutMessage(''), 4000);
} else {
const err = await res.json();
setAboutMessage(`Error: ${err.error}`);
}
} catch {
setAboutMessage('Error: Failed to save');
} finally {
setAboutSaving(false);
}
}
function updateAboutMeta(key: string, value: unknown) {
setAboutMeta((m) => ({ ...m, [key]: value }));
setAboutDirty(true);
setAboutMessage('');
}
if (loading) {
return (
);
}
// Resolved the same way the site resolves it, so the switches show what a
// visitor actually sees — including a config that only ever set the older
// `exifOnHover`.
const exif = resolveExifDisplay(settings.exif, settings.exifOnHover);
// The master switch has no key of its own and needs none: "off" is all four
// groups off, a state the site already acts on — with nothing left to show,
// the lightbox withdraws its info button and the `i` key entirely (#506).
const anyMetadata = exif.camera || exif.settings || exif.location || exif.caption;
const metadataSummary =
[
exif.camera && 'Camera & lens',
exif.settings && 'Exposure',
exif.location && 'Location',
exif.caption && 'Description',
]
.filter(Boolean)
.join(' · ') || 'Nothing published — the lightbox hides its info panel';
/** Turning it back on selects everything; the details below narrow it again. */
const toggleMetadata = () =>
updateMany({
'exif.camera': !anyMetadata,
'exif.settings': !anyMetadata,
'exif.location': !anyMetadata,
'exif.caption': !anyMetadata,
});
return (
{/* The About panel edits about.md, not settings.yaml, so it drives the bar itself. */}
{activeSection === 'about' ? (
) : (
)}
Live Sync (No Docker restart required)
{/* Sidebar */}
{/* Content */}
{activeSection === 'general' && (
General Site Settings
Configure basic site identity, language, and core feature toggles.
The visitor-facing interface is translated for {SUPPORTED_LOCALES.join(', ')}.
Other languages still set <html lang> and date formatting, but
show the English interface. The admin panel is always English.
Portfolio Features & Modules
Enable or disable optional portfolio modules, privacy analytics, and map widgets.
}
title="Pages & motion"
description="Chrome the visitor sees on every page."
>
}
title="What each photo reveals"
description="Published to anyone who opens a photo."
chip="visible to visitors"
>
{metadataOpen && (
update('exif.camera', !exif.camera)}
/>
update('exif.settings', !exif.settings)}
/>
update('exif.location', !exif.location)}
/>
update('exif.caption', !exif.caption)}
/>
{/* Dependent, not a peer: this only controls the grid overlay, and
the overlay carries camera and lens. With those off it has
nothing to show, which used to leave it switched on and
silently doing nothing (#510). */}
update('exifOnHover', settings.exifOnHover === false)}
/>
)}
}
title="What visitors can do"
description="Optional pages and interactions."
>
}
title="Measurement"
description="Cookieless and self-hosted — nothing leaves your server."
>
)}
SVG, PNG, ICO, or JPEG — max 512 kB. Stored in the content volume. Reset restores
the bundled default.
)}
{activeSection === 'theme' && (
Theme Presets & Color Mode
Choose a typography preset and preview in Light or Dark mode.
What a first-time visitor sees. The light/dark toggle in the site header still
lets them choose, and their choice is remembered on their device. The preview
below is your own, and does not change this.
Adjust column counts and grid gap spacing. The column count also tiles the album
covers on a subpage; their spacing stays with the theme unless a page overrides
it.
Needed for sitemap.xml, the feed and structured data — those are
generated without a request, so the address cannot be derived from it. Leave empty
and the sitemap stays empty rather than guessing.
{siteUrlInfo?.source === 'env' && (
<>
Currently supplied by the SITE_URL environment variable (
{siteUrlInfo.effective}). A value entered here takes precedence
once saved.
>
)}
update('seo.title', e.target.value)}
placeholder="Overrides default site title for Google search results"
/>
Template for subpages & albums. Use %s as placeholder for the
page title.
Preview:{' '}
{(
settings.seo?.titleTemplate ||
`%s | ${settings.seo?.title || settings.title || 'My Portfolio'}`
).replace('%s', 'Landscapes')}
Search Crawler Directives
Control how Googlebot and other web crawlers index your site.
)}
{activeSection === 'security' && (
Site Password
Puts the whole public site behind one password — useful while a portfolio is still
being built, or for a folio only ever shown to clients.
update('sitePassword', e.target.value)}
placeholder="Leave empty for a public site"
autoComplete="off"
/>
Stored in settings.yaml. Log in once and the server log prints a{' '}
scrypt:… hash to paste back here instead of the plaintext. The{' '}
SITE_PASSWORD environment variable overrides this field. The admin
panel keeps its own password and is never behind this gate.
Asset Protection & Watermark
Configure image protection rules, right-click prevention, and watermark overlay.
Dynamic Watermark Overlay
Overlay copyright branding text on Lightbox images.
{/* Read through the same normaliser the lightbox uses, so a
config written as a percentage shows as "90%" here rather
than "9000%" — and is written back as a fraction on save. */}
update('watermark.opacity', parseFloat(e.target.value))}
/>
>
)}
)}
{activeSection === 'about' && (
About Page
Edit the portrait, biography and gear shown on the About page. Use General to show
or hide the page itself.
{aboutLoading ? (
) : (
<>
updateAboutMeta('portrait', e.target.value)}
placeholder="Immich asset UUID for the portrait photo"
/>