release: v0.20.0

This commit is contained in:
Ralksta
2026-10-02 18:14:23 +02:00
committed by GitHub
parent 81ce886b01
commit 5f831f719a
209 changed files with 13807 additions and 803 deletions

View File

@@ -113,7 +113,8 @@ export default function AnalyticsView() {
<div className="analytics-bar-chart">
{daysList.map(({ date, pageviews }) => {
const heightPct = Math.round((pageviews / maxDayViews) * 100);
const formattedDate = date.slice(5); // MM-DD
const month = date.slice(5, 8); // "MM-"
const day = date.slice(8); // "DD"
return (
<div key={date} className="bar-col" title={`${date}: ${pageviews} views`}>
<div className="bar-wrapper">
@@ -125,7 +126,11 @@ export default function AnalyticsView() {
<span className="bar-val">{pageviews}</span>
</div>
</div>
<span className="bar-label">{formattedDate}</span>
<span className="bar-label">
{/* Hidden at phone width, where only the day fits (A-18). */}
<span className="bar-label-month">{month}</span>
{day}
</span>
</div>
);
})}

View File

@@ -41,7 +41,11 @@ type AltTextData = AltTextReport & {
/** Which group a doctor check belongs to. An unknown id lands in Content. */
const GROUPS: { id: string; title: string; checks: string[] }[] = [
{ id: 'connection', title: 'Connection', checks: ['immich-api', 'album-ids'] },
{ id: 'security', title: 'Security', checks: ['auth-secret', 'passwords', 'proxy-hops', 'cdn'] },
{
id: 'security',
title: 'Security',
checks: ['auth-secret', 'passwords', 'proxy-hops', 'cdn', 'download-metadata'],
},
{
id: 'content',
title: 'Content',
@@ -54,6 +58,7 @@ const GROUPS: { id: string; title: string; checks: string[] }[] = [
'contact',
'privacy',
'settings-values',
'zoom-renditions',
],
},
];
@@ -75,12 +80,17 @@ const FIXES: Record<string, { label: string; href: string }> = {
'album-ids': { label: 'Open pages', href: '/admin/pages' },
'albums-shared': { label: 'Open pages', href: '/admin/pages' },
'content-pages': { label: 'Open pages', href: '/admin/pages' },
'download-metadata': { label: 'Open pages', href: '/admin/pages' },
legal: { label: 'Open settings', href: '/admin/settings/legal' },
contact: { label: 'Open settings', href: '/admin/settings/legal' },
privacy: { label: 'Open settings', href: '/admin/settings/legal' },
// The href is replaced by the finding's own settingsSection.
'settings-values': { label: 'Open settings', href: '/admin/settings/general' },
'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` },
'zoom-renditions': {
label: 'How to set it up',
href: `${DOCS}/docs/gallery-config.md#photo-zoom`,
},
'immich-api': {
label: 'API key permissions',
href: `${DOCS}/README.md#immich-api-key-permissions`,

View File

@@ -54,9 +54,11 @@ import { useLatest } from './useLatest';
import { useVersionedSave } from './useVersionedSave';
import type { GalleryVersionChange } from '@/lib/admin/pageRefs';
import DraftNotice from './DraftNotice';
import { sameDraft } from './sameDraft';
import { reportIfSessionExpired } from './sessionExpiry';
import { IconCamera, IconHome, IconPlus, IconSearch } from './Icons';
import { useNotify } from './Notifications';
import { dndAnnouncements } from './dndAnnouncements';
/** Drop target id of the "Not in menu" group. */
const OFF_MENU_ID = 'offmenu-zone';
@@ -83,7 +85,12 @@ export default function PageBuilder() {
*/
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
/** Set by any edit; `dirty` below also asks whether the edits changed anything. */
const [edited, setDirty] = useState(false);
/** gallery.yaml as last loaded or saved — `serverState`, as state for the comparison. */
const [savedGallery, setSavedGallery] = useState<GalleryState | null>(null);
// Putting an edit back the way it was is not an unsaved change (QA A-19).
const dirty = edited && !(savedGallery && sameDraft(gallery, savedGallery));
const [saveStatus, setSaveStatus] = useState<SaveStatus>(null);
const [expandedSubpage, setExpandedSubpage] = useState<number | null>(null);
/** What the panel shows while no subpage is selected (UX stage 4). */
@@ -205,6 +212,7 @@ export default function PageBuilder() {
versionRef.current = typeof version === 'string' ? version : null;
const parsed = parseGalleryYaml(raw);
serverState.current = parsed;
setSavedGallery(parsed);
const restored = draft.load(JSON.stringify(parsed));
setGallery(restored ?? parsed);
// Not merely "set when restored": on a reload after a backup restore the
@@ -335,6 +343,7 @@ export default function PageBuilder() {
const written = (data.gallery ?? yamlData) as Record<string, unknown>;
const asLoaded = parseGalleryYaml(JSON.parse(JSON.stringify(written)));
serverState.current = asLoaded;
setSavedGallery(asLoaded);
// Edited while the request was out: those edits are not saved yet, so
// they stay dirty and are not replaced by the file as written.
const editedMeanwhile = latestGallery.current !== sent;
@@ -490,6 +499,7 @@ export default function PageBuilder() {
if (change && versionRef.current === change.from) versionRef.current = change.to;
if (serverState.current) {
serverState.current = op(serverState.current);
setSavedGallery(serverState.current);
draft.saved(JSON.stringify(serverState.current));
}
}
@@ -639,6 +649,24 @@ export default function PageBuilder() {
return found?.albumName || id.slice(0, 8) + '...';
}
/** A sortable id as the list shows it, for the drag announcements (QA A-20). */
function dragLabel(id: string | number): string {
const key = String(id);
const subpage = /^subpage-(\d+)$/.exec(key);
if (subpage) return `page "${gallery.subpages[Number(subpage[1])]?.name || 'Untitled'}"`;
const page = /^page-(.+)$/.exec(key);
if (page) {
const found = pages.find((p) => p.slug === page[1]);
return `page "${found?.frontmatter.title || page[1]}"`;
}
const hero = /^hero-(\d+)$/.exec(key);
if (hero) return `homepage hero photo ${Number(hero[1]) + 1}`;
const album = /^album-(.+)-\d+$/.exec(key);
if (album) return `album "${getAlbumName(album[1])}"`;
return 'item';
}
const announcements = dndAnnouncements(dragLabel);
function getAlbumCount(id: string): number {
const found = immichAlbums.find((a) => a.id === id);
return found?.assetCount || 0;
@@ -848,6 +876,7 @@ export default function PageBuilder() {
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleMenuDragEnd}
accessibility={{ announcements }}
>
<div className="pb-group">
<div className="pb-group-head">
@@ -1024,6 +1053,7 @@ export default function PageBuilder() {
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleAlbumDragEnd}
accessibility={{ announcements }}
>
<SortableContext
items={filteredAlbums.map((a) => {
@@ -1071,7 +1101,7 @@ export default function PageBuilder() {
<section className="builder-section">
<div className="builder-section-header">
<h2>
<IconHome /> Homepage Hero
<IconHome /> Hero photos
</h2>
<button
className="admin-btn admin-btn-sm"
@@ -1090,6 +1120,7 @@ export default function PageBuilder() {
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleHeroDragEnd}
accessibility={{ announcements }}
>
<SortableContext
items={gallery.hero.map((_, i) => `hero-${i}`)}

View File

@@ -30,6 +30,7 @@ import SeoSection from './settings/SeoSection';
import SecuritySection from './settings/SecuritySection';
import AboutSection, { useAboutEditor } from './settings/AboutSection';
import PageHeader from './PageHeader';
import { sameDraft } from './sameDraft';
const SETTINGS_SECTIONS = [
{ id: 'general', label: 'General' },
@@ -71,6 +72,8 @@ export default function SettingsEditor() {
} | null>(null);
/** Fields an environment variable overrides, path to variable name (#605). */
const [envLocks, setEnvLocks] = useState<EnvLocks>({});
/** SITE_TITLE, the first fallback for an empty title (QA A-15). */
const [siteTitleEnv, setSiteTitleEnv] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
/**
* Set when the settings could not be fetched. It blocks saving, because an
@@ -79,7 +82,8 @@ export default function SettingsEditor() {
*/
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
/** Set by any edit; `dirty` below also asks whether the edits changed anything. */
const [edited, setDirty] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>(null);
const about = useAboutEditor(activeSection === 'about');
@@ -88,6 +92,8 @@ export default function SettingsEditor() {
// builder's and the journal's do (#592) — useUnsavedGuard only covers
// leaving the browser.
const [serverSettings, setServerSettings] = useState<Settings>({});
// Typing a value back to what was saved is not an unsaved change (QA A-19).
const dirty = edited && !sameDraft(settings, serverSettings);
// The draft never sees `sitePassword`: sessionStorage is readable by any
// script on the admin origin, and until it is saved (and hashed) the field
// holds the password as typed. A restored draft takes the password from the
@@ -160,7 +166,13 @@ export default function SettingsEditor() {
: `The server answered ${res.status}.`,
);
}
const { settings: data, siteUrl, envLocks: locks, version } = await res.json();
const {
settings: data,
siteUrl,
envLocks: locks,
version,
siteTitleEnv: titleEnv,
} = await res.json();
const loaded: Settings = data || {};
versionRef.current = typeof version === 'string' ? version : null;
setServerSettings(loaded);
@@ -170,6 +182,7 @@ export default function SettingsEditor() {
setDirty(restored !== null);
setSiteUrlInfo(siteUrl ?? null);
setEnvLocks(locks ?? {});
setSiteTitleEnv(typeof titleEnv === 'string' ? titleEnv : null);
} catch (err) {
console.error('Failed to load settings:', err);
setLoadError(err instanceof Error ? err.message : 'The settings could not be loaded.');
@@ -407,7 +420,7 @@ export default function SettingsEditor() {
{/* Content */}
<div className="settings-content">
{activeSection === 'general' && <GeneralSection {...props} />}
{activeSection === 'general' && <GeneralSection {...props} siteTitleEnv={siteTitleEnv} />}
{activeSection === 'theme' && <ThemeSection {...props} />}
{activeSection === 'grid' && <GridSection {...props} />}
{activeSection === 'footer' && <FooterSection {...props} />}

View File

@@ -249,11 +249,11 @@ describe('PageBuilder: edits made while a save is in flight', () => {
</ConfirmProvider>
</NotificationProvider>,
);
fireEvent.click(await screen.findByRole('button', { name: /New subpage/ }));
fireEvent.click(await screen.findByRole('button', { name: 'New subpage' }));
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => expect(releasePut).not.toBeNull());
fireEvent.click(screen.getByRole('button', { name: /New subpage/ }));
fireEvent.click(screen.getByRole('button', { name: 'New subpage' }));
await act(async () => releasePut!());
await screen.findByText('Saved successfully!');

View File

@@ -146,6 +146,26 @@ describe('journal editor: text blocks as markdown (A-5)', () => {
});
});
describe('journal editor: mode toggles (A-18)', () => {
// Rendered as admin-btn-xs, the active mode carried admin-btn-primary, whose
// background the size class overrode: both modes looked the same.
it('mark the active mode, starting on Visual Blocks and Desktop', async () => {
await renderJournal();
const pressed = (name: string) =>
screen.getByRole('button', { name }).getAttribute('aria-pressed');
expect(pressed('Visual Blocks')).toBe('true');
expect(pressed('Raw Markdown')).toBe('false');
expect(pressed('Desktop')).toBe('true');
expect(pressed('Mobile')).toBe('false');
fireEvent.click(screen.getByRole('button', { name: 'Raw Markdown' }));
expect(pressed('Visual Blocks')).toBe('false');
expect(pressed('Raw Markdown')).toBe('true');
expect(screen.getByRole('button', { name: 'Raw Markdown' }).className).toContain('active');
});
});
describe('InlineMarkdownField', () => {
function Harness({ initial }: { initial: string }) {
const [html, setHtml] = useState(initial);

View File

@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import GeneralSection from '../settings/GeneralSection';
import ZoomOverrideField from '../fields/ZoomOverrideField';
/**
* The admin switches for the lightbox zoom (#467): one site-wide toggle in
* Settings › General, and an inherit / on / off choice per page and album.
* The YAML they produce is covered by the page builder round-trip tests.
*/
afterEach(cleanup);
describe('Settings › General › Photo zoom', () => {
const renderWith = (settings: Record<string, unknown>) => {
const update = vi.fn();
render(<GeneralSection settings={settings} update={update} updateMany={vi.fn()} />);
return { update, row: screen.getByRole('button', { name: /Photo zoom/ }) };
};
it('is shown off when settings.yaml says nothing, and switches it on', () => {
const { update, row } = renderWith({});
expect(row.getAttribute('aria-pressed')).toBe('false');
fireEvent.click(row);
expect(update).toHaveBeenCalledWith('zoom', true);
});
it('switches it off again', () => {
const { update, row } = renderWith({ zoom: true });
expect(row.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(row);
expect(update).toHaveBeenCalledWith('zoom', false);
});
});
describe('ZoomOverrideField', () => {
const renderField = (value: boolean | undefined) => {
const onChange = vi.fn();
render(
<ZoomOverrideField
id="z"
value={value}
onChange={onChange}
inheritLabel="Inherit"
hint="hint"
/>,
);
return { onChange, select: screen.getByLabelText('Photo zoom') as HTMLSelectElement };
};
it('shows the three states', () => {
expect(renderField(undefined).select.value).toBe('');
cleanup();
expect(renderField(true).select.value).toBe('on');
cleanup();
expect(renderField(false).select.value).toBe('off');
});
it('reports true, false, and undefined for inherit', () => {
const { onChange, select } = renderField(undefined);
fireEvent.change(select, { target: { value: 'on' } });
fireEvent.change(select, { target: { value: 'off' } });
fireEvent.change(select, { target: { value: '' } });
expect(onChange.mock.calls).toEqual([[true], [false], [undefined]]);
});
});

View File

@@ -0,0 +1,28 @@
import type { Announcements, UniqueIdentifier } from '@dnd-kit/core';
/**
* Screen reader announcements for a sortable list, in words (QA A-20).
*
* dnd-kit's defaults read out the sortable ids — "Picked up draggable item
* subpage-3. Draggable item page-pricing was moved over droppable area
* subpage-1." Those ids are internal. `label` turns one into what the list
* shows, e.g. `page "Pricing"`.
*/
export function dndAnnouncements(label: (id: UniqueIdentifier) => string): Announcements {
return {
onDragStart: ({ active }) => `Picked up ${label(active.id)}.`,
onDragOver: ({ active, over }) =>
over
? `${capitalise(label(active.id))} is at the position of ${label(over.id)}.`
: `${capitalise(label(active.id))} is no longer over the list.`,
onDragEnd: ({ active, over }) =>
over
? `${capitalise(label(active.id))} was dropped at the position of ${label(over.id)}.`
: `${capitalise(label(active.id))} was dropped.`,
onDragCancel: ({ active }) => `Moving ${label(active.id)} was cancelled.`,
};
}
function capitalise(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}

View File

@@ -0,0 +1,46 @@
'use client';
import type { ReactNode } from 'react';
/**
* Lightbox zoom for one page or album (#467): inherit, on, or off.
*
* Three states rather than a switch, because `zoom: false` is a setting of its
* own — it switches zoom off here while the site (or the page) has it on —
* and a switch can only say true or absent. A select, like the layout
* override beside it, with inheriting as the empty value, so the YAML keeps no
* key until someone picks one.
*/
export default function ZoomOverrideField({
id,
value,
onChange,
inheritLabel,
hint,
}: {
id: string;
value: boolean | undefined;
onChange: (value: boolean | undefined) => void;
inheritLabel: string;
hint: ReactNode;
}) {
const current = value === true ? 'on' : value === false ? 'off' : '';
return (
<div className="admin-field">
<label htmlFor={id}>Photo zoom</label>
<select
id={id}
value={current}
onChange={(e) => {
const next = e.target.value;
onChange(next === 'on' ? true : next === 'off' ? false : undefined);
}}
>
<option value="">{inheritLabel}</option>
<option value="on">On — visitors can zoom to full resolution</option>
<option value="off">Off</option>
</select>
<span className="admin-field-hint">{hint}</span>
</div>
);
}

View File

@@ -116,12 +116,19 @@ export function BlockFields({
{block.type === 'photo' && (
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<div
{/* A button, not a clickable div, so the keyboard reaches it. */}
<button
type="button"
aria-label={block.assetId ? 'Change photo' : 'Pick a photo'}
style={{
width: '140px',
height: '96px',
flexShrink: 0,
background: 'rgba(0,0,0,0.3)',
border: 'none',
padding: 0,
color: 'inherit',
font: 'inherit',
borderRadius: '6px',
overflow: 'hidden',
display: 'flex',
@@ -140,13 +147,13 @@ export function BlockFields({
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/admin/thumbnail/${block.assetId}`}
alt="Thumb"
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span style={{ fontSize: '0.8rem', opacity: 0.6 }}>+ Pick</span>
)}
</div>
</button>
<div
style={{
@@ -213,10 +220,16 @@ export function BlockFields({
}}
>
{[0, 1].map((pIdx) => (
<div
<button
type="button"
key={pIdx}
aria-label={`${block.assetIds[pIdx] ? 'Change' : 'Pick'} photo ${pIdx + 1} of the pair`}
style={{
height: '90px',
width: '100%',
padding: 0,
color: 'inherit',
font: 'inherit',
background: 'rgba(0,0,0,0.3)',
borderRadius: '6px',
overflow: 'hidden',
@@ -241,13 +254,13 @@ export function BlockFields({
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/admin/thumbnail/${block.assetIds[pIdx]}`}
alt="Thumb"
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span style={{ fontSize: '0.8rem', opacity: 0.6 }}>+ Pick Photo #{pIdx + 1}</span>
)}
</div>
</button>
))}
</div>
<div>

View File

@@ -401,25 +401,26 @@ export function JournalEditor({ slug, mapEnabled, onBack, kind = 'journal' }: Jo
onChange={(e) => handleFrontmatterChange({ title: e.target.value })}
/>
{/* The admin's segmented control (QA A-18). As `admin-btn-xs` buttons the
active one never showed: `.admin-btn-xs` sets its own background
after `.admin-btn-primary`, so both modes looked identical. */}
<div
style={{
display: 'flex',
gap: '4px',
background: 'rgba(255,255,255,0.06)',
borderRadius: '6px',
padding: '2px',
}}
className="segmented-control segmented-control--sm"
role="group"
aria-label="Editor mode"
>
<button
type="button"
className={`admin-btn admin-btn-xs ${editorMode === 'blocks' ? 'admin-btn-primary' : ''}`}
className={`segment-btn ${editorMode === 'blocks' ? 'active' : ''}`}
aria-pressed={editorMode === 'blocks'}
onClick={() => setEditorMode('blocks')}
>
Visual Blocks
</button>
<button
type="button"
className={`admin-btn admin-btn-xs ${editorMode === 'markdown' ? 'admin-btn-primary' : ''}`}
className={`segment-btn ${editorMode === 'markdown' ? 'active' : ''}`}
aria-pressed={editorMode === 'markdown'}
onClick={() => setEditorMode('markdown')}
>
Raw Markdown

View File

@@ -92,17 +92,24 @@ export function JournalPreview({ parsed, albumAssets }: JournalPreviewProps) {
<span className="journal-preview-bar-label">
<IconEye size={13} /> Realtime Theme Preview
</span>
<div style={{ display: 'flex', gap: '4px' }}>
{/* Same control and same fix as the editor's mode toggle. */}
<div
className="segmented-control segmented-control--sm"
role="group"
aria-label="Preview width"
>
<button
type="button"
className={`admin-btn admin-btn-xs ${viewport === 'desktop' ? 'admin-btn-primary' : ''}`}
className={`segment-btn ${viewport === 'desktop' ? 'active' : ''}`}
aria-pressed={viewport === 'desktop'}
onClick={() => setViewport('desktop')}
>
Desktop
</button>
<button
type="button"
className={`admin-btn admin-btn-xs ${viewport === 'mobile' ? 'admin-btn-primary' : ''}`}
className={`segment-btn ${viewport === 'mobile' ? 'active' : ''}`}
aria-pressed={viewport === 'mobile'}
onClick={() => setViewport('mobile')}
>
Mobile

View File

@@ -110,11 +110,12 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
}
};
const handleDelete = async (slug: string) => {
const handleDelete = async (slug: string, title?: string) => {
// Named by title, as the page delete is; the slug only when there is none.
const ok = await confirm({
title: `Delete “${slug}”?`,
title: `Delete “${title || slug}”?`,
message: 'A copy is kept and can be restored under Backups.',
confirmLabel: 'Delete',
confirmLabel: 'Delete entry',
danger: true,
});
if (!ok) return;
@@ -244,7 +245,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-danger"
onClick={() => handleDelete(entry.slug)}
onClick={() => handleDelete(entry.slug, entry.frontmatter.title)}
title="Delete entry"
style={{ marginLeft: 'auto' }}
>

View File

@@ -1,8 +1,9 @@
/* ── Journal Studio & Editor CSS ──────────────────────────────────── */
/*
* The studio is a full-width workspace: it breaks out of the 1120px
* `.admin-main` column so the split view has room for editor *and* preview.
* The studio is a full-width workspace: it breaks out of the shared
* `--admin-content-width` column so the split view has room for editor *and*
* preview.
*/
.admin-main:has(> .journal-studio),
.admin-main:has(> .journal-editor-container) {

View File

@@ -38,7 +38,7 @@ export function SortableAlbumCard({
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<div ref={setNodeRef} style={style}>
<AlbumCard
album={album}
name={name}
@@ -46,7 +46,7 @@ export function SortableAlbumCard({
thumbnailId={thumbnailId}
onRemove={onRemove}
onEdit={onEdit}
dragListeners={listeners}
dragListeners={{ ...attributes, ...listeners }}
highlighted={highlighted}
/>
</div>
@@ -86,7 +86,12 @@ export default function AlbumCard({
>
<div className="album-tile-cover">
{dragListeners && (
<div className="album-tile-drag" {...dragListeners} title="Drag to reorder">
<div
className="album-tile-drag"
{...dragListeners}
aria-label={`Reorder ${name}`}
title="Drag to reorder"
>
<IconGripVertical size={18} className="svg-icon svg-drag" />
</div>
)}

View File

@@ -6,6 +6,7 @@ import { Listbox } from '../Listbox';
import { SORT_OPTIONS } from './sortOptions';
import type { AlbumEntry, HeroPickerTarget, OrderEditorTarget } from './types';
import PasswordField from '../fields/PasswordField';
import ZoomOverrideField from '../fields/ZoomOverrideField';
import { useModalDialog } from '@/hooks/useModalDialog';
interface AlbumDrawerProps {
@@ -153,6 +154,14 @@ export default function AlbumDrawer({
</div>
</button>
</div>
<ZoomOverrideField
id="album-zoom"
value={album.zoom}
onChange={(zoom) => onUpdate({ zoom })}
inheritLabel="Inherit from page / site settings"
hint="Lets visitors zoom into a photo in the viewer to check sharpness. JPEG and AVIF originals are shown without their GPS data; HEIC and RAW need Immich's full-size previews. PNG, WebP, GIF and photos edited in Immich are never zoomed. Applies where the album is listed here."
/>
</div>
<div className="admin-sheet-col admin-sheet-col--divided">

View File

@@ -35,8 +35,17 @@ export function SortableHeroTile({
};
return (
<div ref={setNodeRef} style={style} className="hero-tile" {...attributes}>
<div className="hero-tile-drag" {...listeners} title="Drag to reorder">
<div ref={setNodeRef} style={style} className="hero-tile">
{/* The handle, not the tile, is the sortable control: the tile holds a
remove button, and a button inside role="button" is nested
interactive content (QA A-20). */}
<div
className="hero-tile-drag"
{...attributes}
{...listeners}
aria-label={`Reorder hero photo ${index + 1}`}
title="Drag to reorder"
>
<IconGripVertical size={18} className="svg-icon svg-drag" />
</div>
<img src={`/api/admin/thumbnail/${id}`} alt="" loading="lazy" />
@@ -88,9 +97,14 @@ export function SortableSubpageTile({
style={style}
className={`subpage-tile ${isActive ? 'active' : ''}`}
onClick={onClick}
{...attributes}
>
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
<div
className="subpage-tile-drag"
{...attributes}
{...listeners}
aria-label={`Reorder ${sp.name || 'page'}`}
title="Drag to reorder"
>
<IconGripVertical size={18} className="svg-icon svg-drag" />
</div>
@@ -185,9 +199,14 @@ export function SortableSubpageRow({
zIndex: isDragging ? 10 : undefined,
}}
className={`pb-row ${isActive ? 'active' : ''} ${sp.enabled === false ? 'is-off' : ''}`}
{...attributes}
>
<span className="pb-row-grip" {...listeners} title="Drag to reorder">
<span
className="pb-row-grip"
{...attributes}
{...listeners}
aria-label={`Reorder ${sp.name || 'page'}`}
title="Drag to reorder"
>
<IconGripVertical size={14} />
</span>
<button type="button" className="pb-row-main" onClick={onClick} aria-current={isActive}>
@@ -252,9 +271,14 @@ export function SortablePageRow({
zIndex: isDragging ? 10 : undefined,
}}
className={`pb-row ${isActive ? 'active' : ''} ${draft || missing ? 'is-off' : ''}`}
{...attributes}
>
<span className="pb-row-grip" {...listeners} title="Drag to reorder">
<span
className="pb-row-grip"
{...attributes}
{...listeners}
aria-label={`Reorder ${title}`}
title="Drag to reorder"
>
<IconGripVertical size={14} />
</span>
<button type="button" className="pb-row-main" onClick={onClick} aria-current={isActive}>

View File

@@ -23,6 +23,7 @@ import {
import AlbumCard, { SortableAlbumCard } from './AlbumCard';
import SubpagePreview from './SubpagePreview';
import PasswordField from '../fields/PasswordField';
import ZoomOverrideField from '../fields/ZoomOverrideField';
import {
normalizeSubpageGrid,
type ActiveEditAlbumAddress,
@@ -120,20 +121,20 @@ export default function SubpageDrawer({
<h3 className="subpage-drawer-title">{sp.title || sp.name || 'Untitled Page'}</h3>
</div>
<div className="subpage-drawer-header-actions">
<div className="segmented-control" style={{ padding: '2px' }}>
<div className="segmented-control segmented-control--sm" role="group" aria-label="View">
<button
type="button"
className={`segment-btn ${drawerMode === 'edit' ? 'active' : ''}`}
aria-pressed={drawerMode === 'edit'}
onClick={() => onDrawerModeChange('edit')}
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
>
<IconPencil size={14} /> Edit
</button>
<button
type="button"
className={`segment-btn ${drawerMode === 'preview' ? 'active' : ''}`}
aria-pressed={drawerMode === 'preview'}
onClick={() => onDrawerModeChange('preview')}
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
>
<IconSearch size={14} /> Live Preview
</button>
@@ -189,7 +190,9 @@ export default function SubpageDrawer({
onChange={(e) =>
updateSubpage(spIndex, { title: e.target.value || undefined })
}
placeholder="Display title (defaults to name)"
// Short enough for the half-width column at any size; the
// old "Display title (defaults to name)" was cut off (A-18).
placeholder="Same as page name"
/>
</div>
<div className="admin-field">
@@ -343,6 +346,19 @@ export default function SubpageDrawer({
</select>
</div>
{/* Zoom belongs to the album viewer; a photo essay has none. */}
{sp.grid?.layout !== 'essay' && sp.essayText == null && !sp.essayFile && (
<div style={{ marginTop: '1rem' }}>
<ZoomOverrideField
id="subpage-zoom"
value={sp.zoom}
onChange={(zoom) => updateSubpage(spIndex, { zoom })}
inheritLabel="Inherit from site settings"
hint="Applies to every album on this page that does not set its own. Settings › General has the site-wide switch."
/>
</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

View File

@@ -30,7 +30,9 @@ export function hasAlbumOptions(entry: AlbumEntry): boolean {
entry.grid ||
entry.coverPosition ||
entry.location ||
entry.download,
entry.download ||
// `zoom: false` is an option too: it switches zoom off for this album.
entry.zoom !== undefined,
);
}
@@ -52,6 +54,7 @@ export function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[
coverPosition: value.coverPosition,
location: value.location,
download: value.download,
zoom: typeof value.zoom === 'boolean' ? value.zoom : undefined,
};
});
}
@@ -82,6 +85,8 @@ export function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
// Same story as location: set by hand in YAML since #475, with no UI, so
// the builder was turning original downloads back off on the next save.
if (entry.download) val.download = entry.download;
// Both values mean something: false switches zoom off for this album.
if (entry.zoom !== undefined) val.zoom = entry.zoom;
return { [entry.id]: val };
});
}

View File

@@ -115,7 +115,7 @@ export const addSubpage = (g: GalleryState): GalleryState => ({
...g,
subpages: [
...g.subpages,
{ name: `New Page ${g.subpages.length + 1}`, albums: [], sections: undefined },
{ name: `New subpage ${g.subpages.length + 1}`, albums: [], sections: undefined },
],
});

View File

@@ -62,6 +62,7 @@ export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
proofing: typeof sp.proofing === 'boolean' ? sp.proofing : undefined,
zoom: typeof sp.zoom === 'boolean' ? sp.zoom : undefined,
location: sp.location as string | undefined,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
@@ -91,6 +92,7 @@ export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
proofing: typeof sp.proofing === 'boolean' ? sp.proofing : undefined,
zoom: typeof sp.zoom === 'boolean' ? sp.zoom : undefined,
location: sp.location as string | undefined,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
@@ -127,6 +129,8 @@ export function serializeGallery(gallery: GalleryState): Record<string, unknown>
if (sp.hidden === true) entry.hidden = true;
// Both values mean something: false switches proofing off for this page.
if (sp.proofing !== undefined) entry.proofing = sp.proofing;
// Likewise for zoom (#467).
if (sp.zoom !== undefined) entry.zoom = sp.zoom;
if (sp.essayText) entry.essayText = sp.essayText;
if (sp.essayFile) entry.essayFile = sp.essayFile;
if (sp.grid) entry.grid = sp.grid;

View File

@@ -24,6 +24,11 @@ export interface AlbumEntry {
location?: string;
/** Whether visitors may download the originals from this album (#475). */
download?: boolean;
/**
* Lightbox zoom for this album: true/false override the page and the site,
* undefined inherits (#467).
*/
zoom?: boolean;
}
export interface Section {
@@ -43,6 +48,11 @@ export interface Subpage {
* direction. No control in the builder yet; carried so a save keeps it.
*/
proofing?: boolean;
/**
* Lightbox zoom for the albums on this page, over the site setting in either
* direction; undefined inherits (#467).
*/
zoom?: boolean;
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
hidden?: boolean;
essayText?: string;

View File

@@ -0,0 +1,31 @@
/**
* Whether an editor's state is still what was last loaded or saved (QA A-19).
*
* The editors set a dirty flag on every edit, so typing a value back to what
* it was left "Unsaved changes" up, the leave guard armed and the draft
* stored. Comparing against the saved state fixes that, but not by plain
* JSON: an edit that is undone does not always undo its shape. The settings
* form creates the parent object of a path it writes (`contact: {}`), and a
* key that is deleted and typed again moves to the end of its object. So the
* comparison ignores key order, `undefined` and empty objects — none of which
* reaches the YAML that is written.
*/
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) {
const c = canonical((value as Record<string, unknown>)[key]);
if (c === undefined) continue;
if (c && typeof c === 'object' && !Array.isArray(c) && Object.keys(c).length === 0) continue;
out[key] = c;
}
return out;
}
return value;
}
export function sameDraft(a: unknown, b: unknown): boolean {
return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
}

View File

@@ -1,9 +1,10 @@
'use client';
import * as Icons from '../Icons';
import { FieldError } from './fields';
import type { SectionProps } from './types';
export default function FooterSection({ settings, update }: SectionProps) {
export default function FooterSection({ settings, update, fieldErrors }: SectionProps) {
return (
<div className="settings-panel">
<div className="settings-section-header">
@@ -67,9 +68,13 @@ export default function FooterSection({ settings, update }: SectionProps) {
{(settings.navLinks || []).map((link, i) => (
<div className="admin-field-row" key={i}>
<div className="admin-field">
<label htmlFor="footer-label">Label</label>
<label htmlFor={`navlink-${i}-label`}>Label</label>
<input
id="footer-label"
id={`navlink-${i}-label`}
aria-invalid={fieldErrors?.[`navLinks.${i}.label`] ? true : undefined}
aria-describedby={
fieldErrors?.[`navLinks.${i}.label`] ? `navlink-${i}-label-error` : undefined
}
value={link.label || ''}
onChange={(e) => {
const next = [...(settings.navLinks || [])];
@@ -78,11 +83,19 @@ export default function FooterSection({ settings, update }: SectionProps) {
}}
placeholder="Shop"
/>
<FieldError
id={`navlink-${i}-label-error`}
message={fieldErrors?.[`navLinks.${i}.label`]}
/>
</div>
<div className="admin-field">
<label htmlFor="footer-url">URL</label>
<label htmlFor={`navlink-${i}-url`}>URL</label>
<input
id="footer-url"
id={`navlink-${i}-url`}
aria-invalid={fieldErrors?.[`navLinks.${i}.url`] ? true : undefined}
aria-describedby={
fieldErrors?.[`navLinks.${i}.url`] ? `navlink-${i}-url-error` : undefined
}
value={link.url || ''}
onChange={(e) => {
const next = [...(settings.navLinks || [])];
@@ -91,6 +104,10 @@ export default function FooterSection({ settings, update }: SectionProps) {
}}
placeholder="https://shop.example.com"
/>
<FieldError
id={`navlink-${i}-url-error`}
message={fieldErrors?.[`navLinks.${i}.url`]}
/>
</div>
<button
type="button"

View File

@@ -4,18 +4,28 @@ import { useState } from 'react';
import * as Icons from '../Icons';
import type { SaveStatus } from '../SaveBar';
import { resolveExifDisplay } from '@/lib/config/schema';
import { SUPPORTED_LOCALES } from '@/lib/i18n';
import { SUPPORTED_LOCALES, getDictionary, resolveLocale } from '@/lib/i18n';
import { FeatureGroup, FeatureRow, SettingRow } from './fields';
import type { SectionProps } from './types';
import { useConfirm } from '../ConfirmDialog';
export default function GeneralSection({ settings, update, updateMany }: SectionProps) {
export default function GeneralSection({
settings,
update,
updateMany,
siteTitleEnv,
}: SectionProps & { siteTitleEnv?: string | null }) {
const confirm = useConfirm();
// What the site shows while the field is empty, resolved as getConfig does:
// SITE_TITLE, else the site language's word for a gallery (QA A-15).
const fallbackTitle = siteTitleEnv || getDictionary(resolveLocale(settings.lang)).common.gallery;
// 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 [faviconUploading, setFaviconUploading] = useState(false);
const [faviconStatus, setFaviconStatus] = useState<SaveStatus>(null);
/** Bumped after an upload or reset so the preview fetches the new icon. */
const [faviconVersion, setFaviconVersion] = useState(0);
// 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
@@ -62,8 +72,14 @@ export default function GeneralSection({ settings, update, updateMany }: Section
id="general-site-title"
value={settings.title || ''}
onChange={(e) => update('title', e.target.value)}
placeholder="My Portfolio"
placeholder={fallbackTitle}
aria-describedby="general-site-title-hint"
/>
<p id="general-site-title-hint" className="admin-field-hint">
{settings.title
? 'Shown in the header, the browser tab and link previews.'
: `Empty, so the site shows “${fallbackTitle}”${siteTitleEnv ? ', from SITE_TITLE' : ''}.`}
</p>
</div>
<div className="admin-field">
<label htmlFor="general-subtitle">Subtitle</label>
@@ -136,6 +152,13 @@ export default function GeneralSection({ settings, update, updateMany }: Section
checked={settings.proofing?.enabled !== false}
onToggle={() => update('proofing.enabled', settings.proofing?.enabled === false)}
/>
<FeatureRow
icon={<Icons.IconSearch size={15} />}
title="Photo zoom"
description="Visitors zoom into a photo to full resolution (1:1) to check sharpness. Pages and albums can override this"
checked={settings.zoom === true}
onToggle={() => update('zoom', !settings.zoom)}
/>
</div>
{/* Shown even while proofing is off globally: a subpage can switch it
on for itself (`proofing: true`), so the address still has to be
@@ -249,12 +272,30 @@ export default function GeneralSection({ settings, update, updateMany }: Section
</FeatureGroup>
<div className="admin-field favicon-field">
<span className="admin-field-label">Favicon</span>
<span className="admin-field-label" id="favicon-label">
Favicon
</span>
<div className="favicon-row">
{/* eslint-disable-next-line @next/next/no-img-element -- the site's own icon route, a 32px preview */}
<img
className="favicon-preview"
src={`/api/favicon?v=${faviconVersion}`}
alt="Current favicon"
width={32}
height={32}
/>
{/* The native file input stays the control — focusable, keyboard
operable, announced as a file picker — but is visually hidden;
its label is drawn as the admin's secondary button, with the
focus ring passed through (QA A-18). */}
<input
id="favicon-file"
className="favicon-file-input"
type="file"
accept=".svg,.png,.ico,.jpg,.jpeg"
disabled={faviconUploading}
aria-labelledby="favicon-label favicon-file-button"
aria-describedby="favicon-hint"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -273,6 +314,7 @@ export default function GeneralSection({ settings, update, updateMany }: Section
? { kind: 'success', message: data.message }
: { kind: 'error', message: `Error: ${data.error}` },
);
if (res.ok) setFaviconVersion((v) => v + 1);
} catch {
setFaviconStatus({ kind: 'error', message: 'Error: Upload failed' });
} finally {
@@ -281,9 +323,16 @@ export default function GeneralSection({ settings, update, updateMany }: Section
}
}}
/>
<label
htmlFor="favicon-file"
id="favicon-file-button"
className="admin-btn admin-btn-sm admin-btn-secondary favicon-upload-btn"
>
<Icons.IconImage size={14} /> Upload icon
</label>
<button
type="button"
className="admin-btn"
className="admin-btn admin-btn-sm admin-btn-secondary"
disabled={faviconUploading}
onClick={async () => {
// Deletes the uploaded file at once, outside the staged form.
@@ -304,6 +353,7 @@ export default function GeneralSection({ settings, update, updateMany }: Section
? { kind: 'success', message: data.message }
: { kind: 'error', message: `Error: ${data.error}` },
);
if (res.ok) setFaviconVersion((v) => v + 1);
} catch {
setFaviconStatus({ kind: 'error', message: 'Error: Reset failed' });
} finally {
@@ -318,7 +368,7 @@ export default function GeneralSection({ settings, update, updateMany }: Section
{faviconStatus && (
<p className={`save-message ${faviconStatus.kind}`}>{faviconStatus.message}</p>
)}
<span className="admin-field-hint">
<span id="favicon-hint" className="admin-field-hint">
SVG, PNG, ICO, or JPEG — max 512 kB. Stored in the content volume. Reset restores the
bundled default.
</span>

View File

@@ -5,9 +5,10 @@ import ToggleCard from '../fields/ToggleCard';
import PrivacyEditor from '../PrivacyEditor';
import EnvLockNote from '../fields/EnvLockNote';
import { CONTACT_RETENTION_DEFAULT, CONTACT_RETENTION_MAX, isHttpUrl } from '@/lib/config/schema';
import { FieldError } from './fields';
import type { SectionProps } from './types';
export default function LegalSection({ settings, update, envLocks }: SectionProps) {
export default function LegalSection({ settings, update, envLocks, fieldErrors }: SectionProps) {
const notifyLock = envLocks?.['contact.notifyUrl'];
// The Impressum drops a non-http(s) contact URL with a warning in the server
// log, where nobody looks; say it here instead.
@@ -237,6 +238,14 @@ export default function LegalSection({ settings, update, envLocks }: SectionProp
parseInt(e.target.value) || CONTACT_RETENTION_DEFAULT,
)
}
aria-invalid={fieldErrors?.['contact.retentionDays'] ? true : undefined}
aria-describedby={
fieldErrors?.['contact.retentionDays'] ? 'contact-retention-error' : undefined
}
/>
<FieldError
id="contact-retention-error"
message={fieldErrors?.['contact.retentionDays']}
/>
</div>
</div>

View File

@@ -4,10 +4,10 @@ import { useState, type CSSProperties } from 'react';
import * as Icons from '../Icons';
import OptionGrid, { toOptions } from '../fields/OptionGrid';
import { DEFAULT_PRESET } from '@/lib/config/theme';
import { PRESET_IDS, PRESET_REGISTRY } from '@/lib/config/presets';
import { effectiveTheme, FeatureRow, FieldError } from './fields';
import type { SectionProps } from './types';
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic', 'cover'];
@@ -36,7 +36,7 @@ const HERO_STYLE_INFO: Record<string, { label: string; desc: string }> = {
fullbleed: { label: 'Fullbleed', desc: 'Edge-to-edge full width banner' },
minimal: { label: 'Minimal', desc: 'Centered title with subtle photo' },
stacked: { label: 'Stacked', desc: 'Title stacked directly over photo' },
typographic: { label: 'Typographic', desc: 'Oversized magazine masthead' },
typographic: { label: 'Typographic', desc: 'Oversized text masthead, no photos' },
mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' },
cover: {
label: 'Cover (Experimental)',
@@ -79,15 +79,26 @@ function HeroStylePreview({ value }: { value: string }) {
<div className="hero-demo-photo banner" />
</>
)}
{/* Text only, like the hero it stands for: the typographic hero
renders no photo, so the card must not promise any. */}
{value === 'typographic' && (
<>
<div className="demo-line title giant" />
<div className="hero-demo-grid2">
<div className="hero-demo-photo" />
<div className="hero-demo-photo" />
<div className="demo-line sub" />
<div className="hero-demo-divider" />
<div className="hero-demo-nav">
<div className="demo-line" />
<div className="demo-line" />
<div className="demo-line" />
</div>
</>
)}
{value === 'cover' && (
<div className="hero-demo-full hero-demo-cover-splash">
<div className="demo-line title light" />
<div className="hero-demo-enter" />
</div>
)}
{value === 'mosaic' && (
<div className="hero-demo-mosaic">
<div className="hero-demo-photo big" />
@@ -103,9 +114,10 @@ function HeroStylePreview({ value }: { value: string }) {
}
/**
* 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.
* Card metadata for the theme picker, from the preset registry
* (lib/config/presets.ts). `font`, `radius` and `frame` are the preset's own
* values so the mini mockups show what the preset actually does; `gap` is the
* visual density of its gallery grid.
*/
const THEME_INFO: Record<
string,
@@ -121,92 +133,19 @@ const THEME_INFO: Record<
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,
},
};
> = Object.fromEntries(
PRESET_REGISTRY.map((p) => [
p.id,
{
label: p.label,
desc: p.description,
...p.card,
font: p.theme.fonts.heading,
radius: p.theme.radius,
frame: p.theme.photoFrame,
},
]),
);
/* The preset group sits below THEME_INFO on purpose. PRESET_OPTIONS is built
at module evaluation and reads it through themeInfo(); declared above, it
@@ -232,7 +171,7 @@ function themeInfo(value: string) {
);
}
const PRESET_OPTIONS = PRESETS.map((value) => ({
const PRESET_OPTIONS = PRESET_IDS.map((value) => ({
value,
label: themeInfo(value).label,
desc: themeInfo(value).desc,

View File

@@ -25,6 +25,8 @@ export interface Settings {
transitions?: boolean;
scrollToTop?: boolean;
analytics?: boolean;
/** Lightbox zoom to full resolution (#467); off unless true. */
zoom?: boolean;
theme?: {
preset?: string;
accent?: string;