release: v0.20.0
This commit is contained in:
61
app/admin/__tests__/SortableTiles.test.tsx
Normal file
61
app/admin/__tests__/SortableTiles.test.tsx
Normal 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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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' } }]);
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
35
app/admin/__tests__/sameDraft.test.ts
Normal file
35
app/admin/__tests__/sameDraft.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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}`)}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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!');
|
||||
|
||||
@@ -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);
|
||||
|
||||
67
app/admin/components/__tests__/zoom-settings.test.tsx
Normal file
67
app/admin/components/__tests__/zoom-settings.test.tsx
Normal 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]]);
|
||||
});
|
||||
});
|
||||
28
app/admin/components/dndAnnouncements.ts
Normal file
28
app/admin/components/dndAnnouncements.ts
Normal 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);
|
||||
}
|
||||
46
app/admin/components/fields/ZoomOverrideField.tsx
Normal file
46
app/admin/components/fields/ZoomOverrideField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
31
app/admin/components/sameDraft.ts
Normal file
31
app/admin/components/sameDraft.ts
Normal 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));
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user