release: v0.12.0 (#520)

Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
This commit is contained in:
Ralksta
2026-08-23 23:18:14 +02:00
committed by GitHub
parent 2f546f440d
commit 10ec68f493
119 changed files with 6888 additions and 758 deletions

View File

@@ -1581,6 +1581,323 @@
flex-direction: column;
}
/* ── Grouped feature settings (#510) ──────────────────────────
The features panel used to be one flat grid of identical cards. These give it
the structure the config already had: labelled groups, and sub-settings that
look like sub-settings instead of peers. */
.settings-group {
margin-bottom: 1.75rem;
}
.settings-group-head {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-bottom: 0.65rem;
}
.settings-group-title {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--admin-text-secondary);
}
/* Marks a group whose switches publish something, so it does not read like the
one next to it that controls a fade. */
.settings-group-chip {
margin-left: 4px;
padding: 2px 7px;
border: 1px solid var(--admin-border);
border-radius: 999px;
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--admin-text-secondary);
}
.settings-group-desc {
font-size: 0.75rem;
color: var(--admin-text-muted);
line-height: 1.35;
}
.settings-subpanel {
padding: 1rem 1.25rem;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
}
.settings-subpanel-head {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding-bottom: 0.75rem;
}
.settings-subpanel-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.88rem;
font-weight: 600;
color: var(--admin-text);
}
.settings-subpanel-desc {
font-size: 0.75rem;
color: var(--admin-text-muted);
}
/* Quiet marker on an album Immich still considers private (#515). Deliberately
not styled as a warning: publishing it is allowed, this only says what it is. */
.picker-item-note {
display: inline-flex;
align-items: center;
margin-top: 0.2rem;
font-size: 0.68rem;
letter-spacing: 0.02em;
color: var(--admin-text-muted);
}
/* ── Config doctor report (#491) ──────────────────────────────
Each finding is one line with a severity dot: the point is scanning a list
for the ones that are not green, not reading prose. */
.doctor-list {
margin: 0;
padding: 0;
list-style: none;
}
.doctor-item {
display: flex;
align-items: flex-start;
gap: 0.7rem;
padding: 0.8rem 0;
border-bottom: 1px solid var(--admin-border);
}
.doctor-item:last-child {
border-bottom: none;
}
.doctor-dot {
width: 8px;
height: 8px;
margin-top: 0.42rem;
border-radius: 50%;
flex-shrink: 0;
background: var(--admin-success);
}
.doctor-item--warn .doctor-dot {
background: var(--admin-accent);
}
.doctor-item--error .doctor-dot {
background: var(--admin-error);
}
.doctor-item-body {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.doctor-item-title {
font-size: 0.84rem;
font-weight: 600;
color: var(--admin-text);
}
/* A finding that needs nothing recedes; the eye should land on the others. */
.doctor-item--ok .doctor-item-title {
font-weight: 500;
color: var(--admin-text-secondary);
}
.doctor-item-detail {
font-size: 0.75rem;
line-height: 1.45;
color: var(--admin-text-muted);
}
/* The metadata group as one card with an expander: four permanently visible
sub-switches read as four decisions, when they are details of one. */
.metadata-card {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
overflow: hidden;
transition: border-color 0.15s ease;
}
.metadata-card.active {
border-color: var(--admin-accent);
box-shadow: 0 0 0 1px var(--admin-accent);
}
.metadata-card-main {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
background: none;
border: none;
color: inherit;
text-align: left;
font: inherit;
cursor: pointer;
}
.metadata-details-btn {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 0 1.25rem 0.9rem;
padding: 0;
background: none;
border: none;
color: var(--admin-text-secondary);
font: inherit;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
}
.metadata-details-btn:hover {
color: var(--admin-text);
}
.metadata-details-chevron {
transition: transform 0.15s ease;
}
.metadata-details-chevron.open {
transform: rotate(180deg);
}
.metadata-details {
padding: 0 1.25rem 0.6rem;
border-top: 1px solid var(--admin-border);
}
/* The first row sits directly under that border and does not need its own. */
.metadata-details .setting-row:first-child {
border-top: none;
}
.setting-row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 0;
background: none;
border: none;
border-top: 1px solid var(--admin-border);
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
/* A sub-setting of the rows above it — indented so the dependency is visible
rather than only true. */
.setting-row--indented {
padding-left: 0.9rem;
margin-top: 0.35rem;
}
.setting-row--disabled {
cursor: default;
}
.setting-row--disabled .setting-row-title,
.setting-row--disabled .setting-row-desc {
color: var(--admin-text-muted);
}
.setting-row-info {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.setting-row-title {
font-size: 0.81rem;
font-weight: 500;
color: var(--admin-text);
}
.setting-row-desc {
font-size: 0.72rem;
color: var(--admin-text-muted);
line-height: 1.3;
}
/* Says why an inert row is inert, instead of leaving it looking broken. */
.setting-row-hint {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 0.2rem;
font-size: 0.69rem;
color: var(--admin-text-secondary);
}
.switch-toggle--sm {
width: 34px;
height: 20px;
border-radius: 10px;
}
.switch-toggle--sm .switch-slider {
width: 14px;
height: 14px;
}
.switch-toggle--sm.on .switch-slider {
transform: translateX(14px);
}
/* An inert switch must not look like an off switch someone can turn on. */
.setting-row--disabled .switch-toggle {
background: var(--admin-bg-elevated);
}
.setting-row--disabled .switch-slider {
background: var(--admin-text-muted);
}
/* Explains an unfinished installation before the indicators below it, which
would otherwise read as a fault rather than a pending step. */
.status-setup-note {
margin: 0 0 0.35rem;
padding: 0.5rem 0.6rem;
border-radius: 6px;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
color: var(--admin-text-secondary);
font-size: 0.75rem;
line-height: 1.45;
}
.status-setup-note a {
color: var(--admin-text);
text-decoration: underline;
}
.status-item {
display: flex;
justify-content: space-between;

View File

@@ -1,9 +1,11 @@
'use client';
import { useState, useEffect, type ReactNode } from 'react';
import { useState, useEffect, useCallback, type ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import BackupManagerModal from './BackupManagerModal';
import DoctorModal from './DoctorModal';
import type { DoctorLevel } from '@/lib/admin/doctor';
import * as Icons from './Icons';
interface Props {
@@ -26,6 +28,10 @@ export default function AdminDashboard({ onLogout, children }: Props) {
// Diagnostics & Backup state
const [showStatus, setShowStatus] = useState(false);
const [showBackupModal, setShowBackupModal] = useState(false);
const [showDoctor, setShowDoctor] = useState(false);
// The badge's job is "does anything need me?", and the doctor is the only
// check that can answer that beyond "is Immich up" (#491).
const [doctorLevel, setDoctorLevel] = useState<DoctorLevel | null>(null);
const [status, setStatus] = useState<any>(null);
const [statusLoading, setStatusLoading] = useState(false);
// Distinct from `status === null`: the check itself did not run. Without this
@@ -80,16 +86,37 @@ export default function AdminDashboard({ onLogout, children }: Props) {
return healthy ? { className: 'ok', label: okLabel } : { className: 'error', label: badLabel };
};
const fetchDoctor = useCallback(async () => {
try {
const res = await fetch('/api/admin/doctor');
if (!res.ok) return;
const data = await res.json();
setDoctorLevel(data.level ?? null);
} catch {
// A failed check must not colour the badge — "unknown" is not "broken".
}
}, []);
useEffect(() => {
fetchStatus();
}, []);
fetchDoctor();
}, [fetchDoctor]);
const immichIndicator = indicator(
status?.immich?.status === 'connected',
'Connected',
'Disconnected',
);
const configIndicator = indicator(status?.config?.status === 'valid', 'Valid', 'Degraded');
const configIndicator =
!statusLoading && !statusError && status?.config?.status === 'setup'
? { className: 'unknown', label: 'Not set up' }
: indicator(status?.config?.status === 'valid', 'Valid', 'Degraded');
// An installation that was never finished is not a fault. It used to render
// as "System Degraded" with no further hint, which sent operators looking for
// a network problem that did not exist (#507).
const setupIncomplete = !statusLoading && !statusError && status?.setup?.complete === false;
const missingCredentials = status?.setup?.credentials === 'missing';
return (
<div className="admin-dashboard">
@@ -112,7 +139,15 @@ export default function AdminDashboard({ onLogout, children }: Props) {
{/* Diagnostics Badge */}
<div className="status-indicator-container">
<button
className={`status-badge-btn ${immichIndicator.className === 'ok' ? 'connected' : immichIndicator.className === 'error' ? 'disconnected' : 'unknown'}`}
className={`status-badge-btn ${
setupIncomplete || doctorLevel === 'warn'
? 'unknown'
: immichIndicator.className === 'error' || doctorLevel === 'error'
? 'disconnected'
: immichIndicator.className === 'ok'
? 'connected'
: 'unknown'
}`}
onClick={() => {
setShowStatus(!showStatus);
if (!showStatus) fetchStatus();
@@ -123,11 +158,17 @@ export default function AdminDashboard({ onLogout, children }: Props) {
<span className="status-text">
{statusLoading
? 'Checking...'
: immichIndicator.className === 'ok'
? 'System OK'
: setupIncomplete
? 'Setup Incomplete'
: immichIndicator.className === 'error'
? 'System Degraded'
: 'Status Unknown'}
: doctorLevel === 'error'
? 'Needs Attention'
: doctorLevel === 'warn'
? 'Check Diagnostics'
: immichIndicator.className === 'ok'
? 'System OK'
: 'Status Unknown'}
</span>
</button>
@@ -147,6 +188,19 @@ export default function AdminDashboard({ onLogout, children }: Props) {
</button>
</div>
<div className="status-dropdown-body">
{setupIncomplete && (
<p className="status-setup-note">
{missingCredentials
? 'No Immich URL or API key configured. Set IMMICH_API_URL and IMMICH_API_KEY, or run the '
: 'No content/gallery.yaml yet — the public site has nothing to show. Add a page below, or run the '}
<a href="/install" target="_blank" rel="noopener noreferrer">
setup wizard
</a>
{missingCredentials
? '.'
: ' (the one-time token is printed to the server log).'}
</p>
)}
<div className="status-item">
<span className="status-label">Immich Connection</span>
<span className={`status-val ${immichIndicator.className}`}>
@@ -177,6 +231,15 @@ export default function AdminDashboard({ onLogout, children }: Props) {
</div>
</div>
<div className="status-dropdown-footer">
<button
className="admin-btn admin-btn-sm"
onClick={() => {
setShowStatus(false);
setShowDoctor(true);
}}
>
<Icons.IconShieldCheck size={14} /> Diagnostics
</button>
<button
className="admin-btn admin-btn-sm"
onClick={() => {
@@ -226,6 +289,14 @@ export default function AdminDashboard({ onLogout, children }: Props) {
<main className="admin-main">{children}</main>
<DoctorModal
isOpen={showDoctor}
onClose={() => {
setShowDoctor(false);
fetchDoctor();
}}
/>
<BackupManagerModal
isOpen={showBackupModal}
onClose={() => setShowBackupModal(false)}

View File

@@ -11,6 +11,8 @@ interface ImmichAlbumInfo {
thumbnailAssetId: string | null;
assetCount: number;
isConfigured: boolean;
/** Immich's own flag. Undefined on older Immich — silence beats a false alarm. */
shared?: boolean;
}
interface Props {
@@ -89,6 +91,13 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
{album.assetCount} photos
{album.description && ` · ${album.description.slice(0, 40)}`}
</span>
{/* Not a warning and not a barrier: publishing an unshared
album has always worked, because Immich ignores the
?shared=true filter this list is fetched with (#515). It
is here so nobody publishes a private album unaware. */}
{album.shared === false && (
<span className="picker-item-note">Not shared in Immich</span>
)}
</div>
<div className="picker-item-action">
{isUsed ? (

View File

@@ -0,0 +1,131 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
import * as Icons from './Icons';
import { useScrollLock } from './useScrollLock';
interface Props {
isOpen: boolean;
onClose: () => void;
}
const LEVEL_LABEL: Record<DoctorLevel, string> = {
ok: 'OK',
warn: 'Check',
error: 'Problem',
};
/**
* The config doctor's report (#491).
*
* Most "it does not work for me" reports are one of a handful of
* misconfigurations that fail silently — a wrong proxy hop count, a deleted
* album ID, a plaintext password. Each of those already logs a warning to the
* server log, where nobody looks. This puts them where the operator is.
*/
export default function DoctorModal({ isOpen, onClose }: Props) {
const [findings, setFindings] = useState<DoctorFinding[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const run = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/doctor');
if (!res.ok) throw new Error(`Diagnostics failed (${res.status})`);
const data = await res.json();
setFindings(data.findings || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Diagnostics failed');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isOpen) {
setCopied(false);
run();
}
}, [isOpen, run]);
useScrollLock(isOpen);
if (!isOpen) return null;
/** Markdown, so it can be pasted straight into a GitHub issue. */
async function copyReport() {
const body = findings
.map((f) => `- **${LEVEL_LABEL[f.level]}** — ${f.title}\n ${f.detail}`)
.join('\n');
try {
await navigator.clipboard.writeText(`### Immich Folio diagnostics\n\n${body}\n`);
setCopied(true);
} catch {
setError('Could not copy — your browser refused clipboard access.');
}
}
const problems = findings.filter((f) => f.level !== 'ok').length;
return (
<div className="backup-modal-backdrop" onClick={onClose}>
<div className="backup-modal-container" onClick={(e) => e.stopPropagation()}>
<div className="backup-modal-header">
<div>
<h2>Diagnostics</h2>
<p className="backup-modal-subtitle">
{loading
? 'Checking…'
: problems === 0
? 'Nothing needs your attention.'
: `${problems} of ${findings.length} checks want a look.`}
</p>
</div>
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
<Icons.IconX size={18} />
</button>
</div>
<div className="backup-modal-body">
{error && <div className="admin-error">{error}</div>}
{loading && !findings.length ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<ul className="doctor-list">
{findings.map((f) => (
<li key={f.id} className={`doctor-item doctor-item--${f.level}`}>
<span className="doctor-dot" aria-hidden="true" />
<span className="doctor-item-body">
<span className="doctor-item-title">{f.title}</span>
<span className="doctor-item-detail">{f.detail}</span>
</span>
</li>
))}
</ul>
)}
</div>
<div className="backup-modal-footer">
<button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}>
<Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'}
</button>
<button
className="admin-btn admin-btn-sm"
onClick={copyReport}
disabled={loading || !findings.length}
title="Markdown, ready to paste into an issue"
>
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -31,7 +31,12 @@ import {
isAlbumSortMode,
type AlbumSortMode,
} from '@/lib/albumSort';
import type { AlbumEntryObject } from '@/lib/config/schema';
import {
COVER_GRID_COLUMNS_MAX,
COVER_GRID_COLUMNS_MIN,
COVER_GRID_GAP_MAX,
type AlbumEntryObject,
} from '@/lib/config/schema';
import {
IconArrowDown,
IconArrowUp,
@@ -122,6 +127,21 @@ interface Subpage {
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
}
/**
* Drops keys the editor cleared so an emptied field never persists as `null`
* in gallery.yaml, and returns undefined once nothing is left — `handleSave`
* only writes `grid` when it is truthy.
*/
function normalizeSubpageGrid(grid: Subpage['grid']): Subpage['grid'] {
if (!grid) return undefined;
const next = { ...grid };
for (const key of Object.keys(next) as Array<keyof typeof next>) {
const value = next[key];
if (value === undefined || value === '') delete next[key];
}
return Object.keys(next).length > 0 ? next : undefined;
}
interface GalleryState {
hero: string[];
albums: AlbumEntry[];
@@ -1452,6 +1472,80 @@ export default function PageBuilder() {
</option>
</select>
</div>
{/* Album cover grid — hidden in essay mode, which
renders a story instead of a cover grid. The
album count is deliberately not checked: it
changes in this very drawer, and a field that
vanishes while you edit is worse than one that
has no effect yet. */}
{sp.grid?.layout !== 'essay' && sp.essayText == null && (
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Album Cover Grid</label>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="number"
min={COVER_GRID_COLUMNS_MIN}
max={COVER_GRID_COLUMNS_MAX}
value={sp.grid?.columns ?? ''}
placeholder="Columns (site default)"
onChange={(e) => {
const raw = e.target.value;
const columns = raw === '' ? undefined : Number(raw);
updateSubpage(spIndex, {
grid: normalizeSubpageGrid({
...(sp.grid || {}),
columns:
columns != null &&
columns >= COVER_GRID_COLUMNS_MIN &&
columns <= COVER_GRID_COLUMNS_MAX
? columns
: undefined,
}),
});
}}
style={{
padding: '8px 12px',
borderRadius: '6px',
flex: 1,
fontSize: '0.9rem',
}}
/>
<input
type="number"
min={0}
max={COVER_GRID_GAP_MAX}
value={sp.grid?.gap ?? ''}
placeholder="Gap in px (theme default)"
onChange={(e) => {
const raw = e.target.value;
const gap = raw === '' ? undefined : Number(raw);
updateSubpage(spIndex, {
grid: normalizeSubpageGrid({
...(sp.grid || {}),
gap:
gap != null && gap >= 0 && gap <= COVER_GRID_GAP_MAX
? gap
: undefined,
}),
});
}}
style={{
padding: '8px 12px',
borderRadius: '6px',
flex: 1,
fontSize: '0.9rem',
}}
/>
</div>
<p className="admin-field-hint">
Overrides how the album covers are tiled on this page. Leave the
column count empty to follow the site-wide grid setting, and the
gap empty to keep the theme&apos;s own spacing. Tablet widths
show at most two covers per row.
</p>
</div>
)}
</div>
{/* Visual Photo Essay Builder (if layout === 'essay') */}

View File

@@ -8,12 +8,22 @@ 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 { resolveExifDisplay, resolveWatermarkOpacity } from '@/lib/config/schema';
import { SUPPORTED_LOCALES } from '@/lib/i18n';
interface Settings {
title?: string;
subtitle?: 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;
@@ -204,6 +214,91 @@ interface SettingsEditorProps {
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 (
<div className="settings-group">
<div className="settings-group-head">
<span className="settings-group-title">
{icon}
{title}
{chip && <span className="settings-group-chip">{chip}</span>}
</span>
<span className="settings-group-desc">{description}</span>
</div>
{children}
</div>
);
}
/**
* 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 (
<button
type="button"
className={`setting-row${indented ? ' setting-row--indented' : ''}${
disabled ? ' setting-row--disabled' : ''
}`}
onClick={onToggle}
disabled={disabled}
aria-pressed={checked}
>
<span className="setting-row-info">
<span className="setting-row-title">{title}</span>
<span className="setting-row-desc">{description}</span>
{hint && (
<span className="setting-row-hint">
<Icons.IconBan size={11} /> {hint}
</span>
)}
</span>
<span className={`switch-toggle switch-toggle--sm ${checked && !disabled ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
);
}
export default function SettingsEditor({ section }: SettingsEditorProps) {
const router = useRouter();
// An unknown section in the URL falls back to General instead of an empty panel.
@@ -212,6 +307,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
: 'general';
const [settings, setSettings] = useState<Settings>({});
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('');
@@ -330,6 +428,25 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
}
}
/** Write several paths in one state update — used by the metadata master switch. */
function updateMany(entries: Record<string, unknown>) {
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));
@@ -457,6 +574,34 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
);
}
// 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 (
<div className="settings-editor">
{/* The About panel edits about.md, not settings.yaml, so it drives the bar itself. */}
@@ -537,6 +682,11 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<option value="es">Español (ES)</option>
<option value="ja">日本語 (JA)</option>
</select>
<p className="admin-field-hint">
The visitor-facing interface is translated for {SUPPORTED_LOCALES.join(', ')}.
Other languages still set <code>&lt;html lang&gt;</code> and date formatting, but
show the English interface. The admin panel is always English.
</p>
</div>
<div className="settings-section-divider" />
@@ -550,136 +700,224 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
</p>
</div>
<div className="admin-toggle-cards-grid">
<button
type="button"
className={`admin-toggle-card ${settings.exifOnHover !== false ? 'active' : ''}`}
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>
</div>
<div className={`switch-toggle ${settings.exifOnHover !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.map === true ? 'active' : ''}`}
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>
</div>
<div className={`switch-toggle ${settings.map === true ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.transitions !== false ? 'active' : ''}`}
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>
</div>
<div className={`switch-toggle ${settings.transitions !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.scrollToTop !== false ? 'active' : ''}`}
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>
</div>
<div className={`switch-toggle ${settings.scrollToTop !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.analytics !== false ? 'active' : ''}`}
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>
</div>
<div className={`switch-toggle ${settings.analytics !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.proofing?.enabled !== false ? 'active' : ''}`}
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>
</div>
<div
className={`switch-toggle ${settings.proofing?.enabled !== false ? 'on' : ''}`}
<FeatureGroup
icon={<Icons.IconFrame size={13} />}
title="Pages &amp; motion"
description="Chrome the visitor sees on every page."
>
<div className="admin-toggle-cards-grid">
<button
type="button"
className={`admin-toggle-card ${settings.about?.enabled !== false ? 'active' : ''}`}
onClick={() => update('about.enabled', settings.about?.enabled === false)}
>
<span className="switch-slider" />
</div>
</button>
<div className="toggle-card-info">
<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>
</div>
<div
className={`switch-toggle ${settings.about?.enabled !== false ? 'on' : ''}`}
>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.about?.enabled !== false ? 'active' : ''}`}
onClick={() => update('about.enabled', settings.about?.enabled === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> About Page
<button
type="button"
className={`admin-toggle-card ${settings.transitions !== false ? 'active' : ''}`}
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>
</div>
<div className={`switch-toggle ${settings.transitions !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.scrollToTop !== false ? 'active' : ''}`}
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>
</div>
<div className={`switch-toggle ${settings.scrollToTop !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
</div>
</FeatureGroup>
<FeatureGroup
icon={<Icons.IconShieldCheck size={13} />}
title="What each photo reveals"
description="Published to anyone who opens a photo."
chip="visible to visitors"
>
<div className={`metadata-card ${anyMetadata ? 'active' : ''}`}>
<button
type="button"
className="metadata-card-main"
onClick={toggleMetadata}
aria-pressed={anyMetadata}
>
<span className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> Photo metadata
</span>
<span className="toggle-card-desc">{metadataSummary}</span>
</span>
<span className="toggle-card-desc">
Show a portrait, bio, and gear section on your portfolio
<span className={`switch-toggle ${anyMetadata ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</div>
<div className={`switch-toggle ${settings.about?.enabled !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
</div>
</button>
<button
type="button"
className="metadata-details-btn"
onClick={() => setMetadataOpen((open) => !open)}
aria-expanded={metadataOpen}
>
Details
<Icons.IconChevronDown
size={13}
className={`metadata-details-chevron${metadataOpen ? ' open' : ''}`}
/>
</button>
{metadataOpen && (
<div className="metadata-details">
<SettingRow
title="Camera &amp; Lens"
description="Body, lens and focal length"
checked={exif.camera}
onToggle={() => update('exif.camera', !exif.camera)}
/>
<SettingRow
title="Exposure Settings"
description="Aperture, shutter speed and ISO"
checked={exif.settings}
onToggle={() => update('exif.settings', !exif.settings)}
/>
<SettingRow
title="Location"
description="City and country from the photo's GPS data"
checked={exif.location}
onToggle={() => update('exif.location', !exif.location)}
/>
<SettingRow
title="Photo Description"
description="The description written in Immich"
checked={exif.caption}
onToggle={() => 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). */}
<SettingRow
indented
title="Summary on grid hover"
description="Camera and lens over the photo, not only in the lightbox"
checked={settings.exifOnHover !== false}
disabled={!exif.camera}
hint={exif.camera ? undefined : 'Needs Camera & Lens'}
onToggle={() => update('exifOnHover', settings.exifOnHover === false)}
/>
</div>
)}
</div>
</FeatureGroup>
<FeatureGroup
icon={<Icons.IconHeart size={13} />}
title="What visitors can do"
description="Optional pages and interactions."
>
<div className="admin-toggle-cards-grid">
<button
type="button"
className={`admin-toggle-card ${settings.map === true ? 'active' : ''}`}
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>
</div>
<div className={`switch-toggle ${settings.map === true ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.proofing?.enabled !== false ? 'active' : ''}`}
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>
</div>
<div
className={`switch-toggle ${settings.proofing?.enabled !== false ? 'on' : ''}`}
>
<span className="switch-slider" />
</div>
</button>
</div>
</FeatureGroup>
<FeatureGroup
icon={<Icons.IconBarChart size={13} />}
title="Measurement"
description="Cookieless and self-hosted — nothing leaves your server."
>
<div className="admin-toggle-cards-grid">
<button
type="button"
className={`admin-toggle-card ${settings.analytics !== false ? 'active' : ''}`}
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>
</div>
<div className={`switch-toggle ${settings.analytics !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
</div>
</FeatureGroup>
<div className="admin-field favicon-field">
<label>Favicon</label>
@@ -758,6 +996,23 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
</p>
</div>
<div className="admin-field">
<label>Visitor Default Mode</label>
<select
value={settings.mode || 'dark'}
onChange={(e) => update('mode', e.target.value)}
>
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="auto">Follow the visitor's system</option>
</select>
<p className="admin-field-hint">
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.
</p>
</div>
<div className="admin-field">
<label>Preset</label>
<div className="preset-card-grid">
@@ -1256,7 +1511,11 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<h3>
<Icons.IconColumns size={18} /> Spacing &amp; Columns
</h3>
<p className="settings-section-sub">Adjust column counts and grid gap spacing.</p>
<p className="settings-section-sub">
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.
</p>
</div>
<div className="admin-field-row">
@@ -1618,6 +1877,35 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
{activeSection === 'security' && (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconLock size={18} /> Site Password
</h3>
<p className="settings-section-sub">
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.
</p>
</div>
<div className="admin-field">
<label>Site Password</label>
<input
type="text"
value={settings.sitePassword || ''}
onChange={(e) => update('sitePassword', e.target.value)}
placeholder="Leave empty for a public site"
autoComplete="off"
/>
<p className="admin-field-hint">
Stored in <code>settings.yaml</code>. Log in once and the server log prints a{' '}
<code>scrypt:…</code> hash to paste back here instead of the plaintext. The{' '}
<code>SITE_PASSWORD</code> environment variable overrides this field. The admin
panel keeps its own password and is never behind this gate.
</p>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconShieldCheck size={18} /> Asset Protection &amp; Watermark
@@ -1730,15 +2018,20 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
</select>
</div>
<div className="admin-field">
{/* 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. */}
<label>
Opacity ({Math.round((settings.watermark?.opacity ?? 0.3) * 100)}%)
Opacity (
{Math.round(resolveWatermarkOpacity(settings.watermark?.opacity) * 100)}
%)
</label>
<input
type="range"
min="0.1"
max="0.8"
max="1"
step="0.05"
value={settings.watermark?.opacity ?? 0.3}
value={resolveWatermarkOpacity(settings.watermark?.opacity)}
onChange={(e) => update('watermark.opacity', parseFloat(e.target.value))}
/>
</div>