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

@@ -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>