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

@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { DndContext } from '@dnd-kit/core';
import { SortableContext } from '@dnd-kit/sortable';
import { SortablePageRow, SortableHeroTile } from '../components/page-builder/SortableTiles';
import { dndAnnouncements } from '../components/dndAnnouncements';
afterEach(cleanup);
/**
* QA A-20: the sortable attributes (role="button", tabindex) sat on the whole
* row, which also holds the row's own button — nested interactive content. They
* belong on the drag handle. And the drag announcements read out internal ids.
*/
describe('sortable rows', () => {
it('put the sortable role on the handle, not around the row button', () => {
render(
<DndContext>
<SortableContext items={['page-pricing']}>
<SortablePageRow id="page-pricing" title="Pricing" isActive={false} onClick={() => {}} />
</SortableContext>
</DndContext>,
);
const handle = screen.getByLabelText('Reorder Pricing');
expect(handle.getAttribute('role')).toBe('button');
for (const button of screen.getAllByRole('button')) {
expect(button.parentElement?.closest('[role="button"]')).toBeNull();
}
});
it('keeps the hero tile remove button out of the handle', () => {
render(
<DndContext>
<SortableContext items={['hero-0']}>
<SortableHeroTile id="abc" index={0} onRemove={() => {}} />
</SortableContext>
</DndContext>,
);
const remove = screen.getByTitle('Remove');
expect(remove.parentElement?.closest('[role="button"]')).toBeNull();
expect(screen.getByLabelText('Reorder hero photo 1').getAttribute('role')).toBe('button');
});
});
describe('dndAnnouncements', () => {
const label = (id: string | number) => (id === 'page-pricing' ? 'page "Pricing"' : 'page "FAQ"');
const a = dndAnnouncements(label);
const active = { id: 'page-pricing' } as never;
const over = { id: 'subpage-3' } as never;
it('speaks the names the list shows, not the ids', () => {
expect(a.onDragStart({ active })).toBe('Picked up page "Pricing".');
expect(a.onDragEnd({ active, over } as never)).toBe(
'Page "Pricing" was dropped at the position of page "FAQ".',
);
expect(a.onDragCancel({ active, over: null } as never)).toBe(
'Moving page "Pricing" was cancelled.',
);
});
});

View File

@@ -35,6 +35,7 @@ const everyOption: Required<Omit<AlbumEntryObject, 'grid'>> & Pick<AlbumEntryObj
assetOrder: ['33333333-3333-3333-3333-333333333333'],
coverPosition: '50% 25%',
download: true,
zoom: false,
location: 'hidden',
grid: { columns: 4, gap: 8, aspectRatio: '3/2', layout: 'masonry' },
};
@@ -47,6 +48,17 @@ describe('album entry round trip', () => {
expect(serialized).toEqual({ [uuid]: everyOption });
});
it('keeps zoom: false, which switches zoom off for the album (#467)', () => {
const parsed = parseAlbumEntries([{ [uuid]: { zoom: false } }]);
expect(parsed[0].zoom).toBe(false);
expect(hasAlbumOptions(parsed[0])).toBe(true);
expect(serializeAlbumEntries(parsed)).toEqual([{ [uuid]: { zoom: false } }]);
});
it('writes no zoom key while the album inherits', () => {
expect(serializeAlbumEntries([{ id: uuid, zoom: undefined }])).toEqual([uuid]);
});
it('keeps map precision across a save', () => {
const parsed = parseAlbumEntries([{ [uuid]: { location: 'city' } }]);

View File

@@ -68,7 +68,7 @@ describe('hero, subpages and sections', () => {
it('names a new subpage after its position and starts it empty', () => {
const g = ops.addSubpage(state());
expect(g.subpages[3]).toEqual({ name: 'New Page 4', albums: [], sections: undefined });
expect(g.subpages[3]).toEqual({ name: 'New subpage 4', albums: [], sections: undefined });
});
it('adds, updates and removes sections', () => {

View File

@@ -138,6 +138,7 @@ const everySubpageKey: Required<SubpageYaml> = {
sections: [{ title: 'North', description: 'Cold', albums: [B] }],
password: 'scrypt:aa:bb',
proofing: false,
zoom: true,
essayFile: 'story',
essayText: 'Text',
enabled: false,
@@ -161,6 +162,18 @@ describe('subpage keys', () => {
});
});
describe('subpage zoom (#467)', () => {
it('keeps zoom in either direction, and writes nothing when unset', () => {
const off = { name: 'Z', zoom: false, albums: [A] };
const on = { name: 'Z', zoom: true, albums: [A] };
expect(roundTrip({ subpages: [off] })).toEqual({ subpages: [off] });
expect(roundTrip({ subpages: [on] })).toEqual({ subpages: [on] });
expect(roundTrip({ subpages: [{ name: 'Z', albums: [A] }] })).toEqual({
subpages: [{ name: 'Z', albums: [A] }],
});
});
});
describe('content page references (#722)', () => {
it('keeps `- page:` entries where they were among the subpages', () => {
const yaml = {

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { sameDraft } from '../components/sameDraft';
/**
* "Unsaved changes" stayed up after an edit was typed back to the saved value
* (QA A-19): the editors only ever set their dirty flag. They now also compare
* with the saved state, which has to see through what an undone edit leaves.
*/
describe('sameDraft', () => {
it('is false for a real change', () => {
expect(sameDraft({ title: 'A' }, { title: 'B' })).toBe(false);
expect(sameDraft({ navLinks: [{ label: 'A' }] }, { navLinks: [] })).toBe(false);
expect(sameDraft({ albums: ['a', 'b'] }, { albums: ['b', 'a'] })).toBe(false);
});
it('ignores key order — a deleted key typed again moves to the end', () => {
expect(
sameDraft(
{ theme: { accent: '#fff', preset: 'noir' } },
{
theme: { preset: 'noir', accent: '#fff' },
},
),
).toBe(true);
});
it('ignores the empty parent objects a settings edit creates', () => {
expect(sameDraft({ title: 'A', contact: {} }, { title: 'A' })).toBe(true);
expect(sameDraft({ theme: { fonts: {} } }, {})).toBe(true);
});
it('ignores undefined, which the YAML never holds', () => {
expect(sameDraft({ title: 'A', subtitle: undefined }, { title: 'A' })).toBe(true);
});
});

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;

View File

@@ -7,7 +7,6 @@
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 1100px;
}
.analytics-header {
@@ -54,11 +53,15 @@
color: var(--admin-text-muted);
}
/* Set like the Overview's stat numbers. `--font-mono` was never defined, so
these fell through to the browser's bare `monospace`. Tabular figures keep
the digits aligned without it. */
.metric-value {
font-size: 1.8rem;
font-weight: 700;
font-weight: 500;
letter-spacing: -0.02em;
color: var(--admin-text);
font-family: var(--font-mono, monospace);
font-variant-numeric: tabular-nums;
}
.metric-sub {
@@ -138,7 +141,8 @@
font-size: 0.68rem;
color: var(--admin-text-muted);
margin-top: 0.5rem;
font-family: var(--font-mono, monospace);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.analytics-top-pages {
@@ -154,14 +158,17 @@
}
.page-rank {
flex-shrink: 0;
font-size: 0.8rem;
font-family: var(--font-mono, monospace);
font-variant-numeric: tabular-nums;
color: var(--admin-text-muted);
width: 24px;
}
/* min-width: 0 lets a long path wrap instead of pushing the page sideways. */
.page-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
@@ -169,17 +176,23 @@
.page-path-line {
display: flex;
align-items: center;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.85rem;
}
/* Paths are the one thing here set in monospace, as slugs are elsewhere. */
.page-path {
font-family: var(--font-mono, monospace);
min-width: 0;
overflow-wrap: anywhere;
font-family: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
color: var(--admin-text);
}
.page-count {
flex-shrink: 0;
white-space: nowrap;
font-size: 0.75rem;
color: var(--admin-text-muted);
}
@@ -202,6 +215,32 @@
transition: width 0.3s ease;
}
/* Phone width (QA A-18). The page was 513px wide at 390: a long path could
not wrap (fixed above), and fourteen "09-19" labels broke over two lines
under bars squeezed by 12px gaps. Panel padding and gaps tighten, and the
label drops the month, which every bar's title still carries. */
@media (max-width: 640px) {
.analytics-panel {
padding: 1.25rem 1rem;
}
.analytics-bar-chart {
gap: 0.2rem;
}
.bar-col {
min-width: 0;
}
.bar-label-month {
display: none;
}
.top-page-row {
gap: 0.6rem;
}
}
.analytics-empty {
margin: 0;
padding: 2rem 1rem;

View File

@@ -85,6 +85,13 @@
color: #fca5a5;
}
/* Small buttons carry short action labels ("+ Add Album", "Pick several");
broken over two lines in a tight row they read as two buttons. */
.admin-btn-sm,
.admin-btn-xs {
white-space: nowrap;
}
.admin-btn-sm {
padding: 0 0.8rem;
min-height: 30px;

View File

@@ -3,9 +3,9 @@
/* Each check is one line with a severity dot: the point is scanning a list for
the ones that are not green, not reading prose. Green ones recede. */
/* Fills the shared content column (--admin-content-width); the check details
keep their own 70ch measure below. */
.diag-page {
max-width: 880px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;

View File

@@ -9,11 +9,62 @@
}
.favicon-row {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.favicon-row input[type='file'] {
font-size: 0.8rem;
/* The current icon, on the input surface so a dark or a light icon both show. */
.favicon-preview {
width: 32px;
height: 32px;
padding: 4px;
box-sizing: content-box;
object-fit: contain;
background: var(--admin-bg-input);
border: 1px solid var(--admin-border);
border-radius: 6px;
}
/* The native input stays in the tab order and the accessibility tree but out
of sight: its "Choose File / No file chosen" box was the one raw browser
control left in the admin (QA A-18). The label below is what is seen.
Doubled class to outrank `.admin-field input`, which sizes every input. */
.admin-field .favicon-file-input.favicon-file-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* A <label>, so `.admin-field label` (block, uppercase caption) reaches it
too; this puts the button back. */
.admin-field .favicon-upload-btn {
display: inline-flex;
margin: 0;
font-size: 0.8rem;
font-weight: 500;
letter-spacing: normal;
text-transform: none;
cursor: pointer;
}
/* Focus lands on the hidden input; the ring is drawn on its label. */
.favicon-file-input:focus-visible + .favicon-upload-btn {
outline: var(--admin-focus-ring);
outline-offset: 2px;
}
.favicon-file-input:disabled + .favicon-upload-btn {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}

View File

@@ -4,7 +4,6 @@
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 900px;
}
.messages-count {
@@ -113,6 +112,7 @@
}
.messages-text {
max-width: 70ch;
margin: 0 0 1rem;
white-space: pre-wrap;
line-height: 1.55;

View File

@@ -27,6 +27,14 @@
--admin-sidebar-bg: #090909;
--admin-radius: 4px;
--admin-sidebar-width: 232px;
/* The one outer content column every workspace screen shares. Pages,
Overview and Diagnostics used to end at three different right edges
(QA A-18). Up to a 1592px window every screen simply fills the
workspace; wider, they all stop here instead of each at its own cap.
Text inside keeps its own narrower measure (the 70ch details). Only the
split editors (journal, content pages) break out of it. */
--admin-content-width: 1280px;
--admin-workspace-pad-x: 2.5rem;
font-family: var(--admin-font);
}
@@ -276,8 +284,8 @@
}
.admin-workspace {
max-width: 1180px;
padding: 2.25rem 2.5rem 6rem;
max-width: calc(var(--admin-content-width) + 2 * var(--admin-workspace-pad-x));
padding: 2.25rem var(--admin-workspace-pad-x) 6rem;
margin: 0;
}
@@ -338,21 +346,26 @@
/* Section and field labels in the site's caption voice. */
.admin-layout .admin-field > label,
.admin-layout .settings-group-title,
.admin-layout .analytics-section-title h3 {
.admin-layout .settings-group-title {
font-family: var(--admin-font-label);
letter-spacing: 0.12em;
}
/* Panel headings are headings, not captions: the caption font made the
analytics titles a mixed-case line of monospace (QA A-18). They read like
the settings section headings; monospace stays with the paths. */
.admin-layout .settings-section-header h3,
.admin-layout .analytics-header h2 {
.admin-layout .analytics-header h2,
.admin-layout .analytics-section-title h3 {
font-family: var(--admin-font);
font-weight: 500;
letter-spacing: -0.01em;
}
/* Section icons stay neutral; the accent is not decoration. */
.admin-layout .settings-section-header h3 svg,
.admin-layout .analytics-header h2 svg {
.admin-layout .analytics-header h2 svg,
.admin-layout .analytics-section-title h3 svg {
color: var(--admin-text-muted);
}
@@ -463,6 +476,7 @@
.overview-attention-detail {
display: block;
max-width: 70ch;
margin-top: 0.15rem;
color: var(--admin-text-muted);
font-size: 0.8rem;
@@ -625,8 +639,8 @@
/* The journal list is an ordinary screen and gets the workspace column; only
the editor (split view) needs the full width journal-studio.css gives it. */
.admin-app .admin-main:has(> .journal-studio) {
max-width: 1180px;
padding: 2.25rem 2.5rem 6rem;
max-width: calc(var(--admin-content-width) + 2 * var(--admin-workspace-pad-x));
padding: 2.25rem var(--admin-workspace-pad-x) 6rem;
}
@media (max-width: 860px) {
@@ -701,12 +715,6 @@
/* ── Page builder: structure list + panel (stage 4) ────────── */
/* The builder uses the full workspace width: the list and the panel both
need room, and the panel carries the live preview. */
.admin-app .admin-main:has(.pb-split) {
max-width: none;
}
.pb-split {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);

View File

@@ -52,6 +52,9 @@
.settings-content {
min-width: 0;
/* Room below the last input for the floating save bar (fixed, ~4rem tall
at 1.5rem from the bottom), so it never sits on top of a field. */
padding-bottom: 6rem;
}
.settings-panel h3 {

View File

@@ -81,7 +81,7 @@
flex: 1;
/* Extra bottom padding so the floating save bar never covers page content. */
padding: 1.5rem 1.5rem 6rem;
max-width: 1120px;
max-width: calc(var(--admin-content-width) + 3rem);
margin: 0 auto;
width: 100%;
}

View File

@@ -142,12 +142,46 @@
.hero-demo-typographic {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.hero-demo-grid2 {
.hero-demo-typographic .demo-line.title.giant {
height: 10px;
margin-bottom: 0;
}
.hero-demo-divider {
width: 12%;
height: 1px;
margin: 0.1rem 0;
background: var(--admin-text-muted);
}
.hero-demo-nav {
display: flex;
gap: 0.25rem;
flex: 1;
flex-direction: column;
align-items: center;
gap: 0.2rem;
width: 100%;
}
.hero-demo-nav .demo-line {
width: 30%;
height: 2px;
}
.hero-demo-cover-splash {
flex-direction: column;
gap: 0.3rem;
}
.hero-demo-enter {
width: 22%;
height: 6px;
border: 1px solid #ffffff;
border-radius: 2px;
}
.hero-demo-mosaic {
@@ -322,6 +356,31 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.segment-btn:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 1px;
}
/* Compact size for toolbars: the editor's Visual Blocks / Raw Markdown, its
preview's Desktop / Mobile and the subpage drawer's Edit / Live Preview.
Replaces the inline padding the drawer used to carry. */
.segmented-control--sm {
padding: 2px;
gap: 2px;
border-radius: 8px;
}
.segmented-control--sm .segment-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.6rem;
min-height: 24px;
font-size: 0.75rem;
border-radius: 6px;
white-space: nowrap;
}
/* Accent Color Swatches */
.accent-picker-wrapper {
display: flex;