release: v0.19.0

Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -49,8 +49,9 @@ export default function AdminShell({ children }: { children: ReactNode }) {
if (authenticated === null) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
<div className="admin-loading" role="status">
<div className="admin-spinner" aria-hidden="true" />
<span className="sr-only">Loading admin panel</span>
</div>
);
}

View File

@@ -0,0 +1,58 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import AdminOverview from '../components/AdminOverview';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
/** Every admin endpoint answers 404 except gallery.yaml, which holds `gallery`. */
function stubGallery(gallery: unknown) {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) =>
url === '/api/admin/gallery'
? new Response(JSON.stringify({ gallery, version: 'v' }), { status: 200 })
: new Response(JSON.stringify({ error: 'nope' }), { status: 404 }),
),
);
}
/** The "Pages" tile: its number and the note under it, once loaded. */
async function pagesTile() {
const label = await screen.findByText('Pages');
const tile = label.closest('a')!;
const value = () => tile.querySelector('.overview-stat-value')!.textContent;
await waitFor(() => expect(value()).not.toBe('–'));
return { value: value(), note: tile.querySelector('.overview-stat-note')!.textContent };
}
describe('AdminOverview — Pages tile', () => {
it('counts subpages written in the older map form of gallery.yaml', async () => {
// `subpages:` as a map of name → albums (or name → object) is still a
// valid gallery.yaml — getConfig() and the page builder both read it.
// The overview called .filter() on it, and the first admin screen crashed.
stubGallery({
subpages: {
Weddings: ['11111111-1111-4111-8111-111111111111'],
Travel: { albums: ['22222222-2222-4222-8222-222222222222'], enabled: false },
},
});
render(<AdminOverview />);
expect(await pagesTile()).toEqual({ value: '1', note: '1 switched off' });
});
it('leaves content-page menu entries out of the list form', async () => {
stubGallery({
subpages: [
{ name: 'Weddings', albums: ['11111111-1111-4111-8111-111111111111'] },
{ page: 'pricing' },
{ name: 'Old', albums: [], enabled: false },
],
});
render(<AdminOverview />);
expect(await pagesTile()).toEqual({ value: '1', note: '1 switched off' });
});
});

View File

@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
import AssetPicker from '../components/AssetPicker';
/**
* A fetch whose answers the test hands out by hand, in any order — the
* situation of a slow request still running when the operator moves on.
*/
function deferredFetch() {
const pending: Array<{ url: string; resolve: (body: unknown) => void }> = [];
const fetchMock = vi.fn(
(url: string) =>
new Promise<Response>((resolve) => {
pending.push({
url,
resolve: (body) =>
resolve({ ok: true, status: 200, json: async () => body } as unknown as Response),
});
}),
);
/** Answer the first open request whose URL contains `match`. */
const answer = async (match: string, body: unknown) => {
const i = pending.findIndex((p) => p.url.includes(match));
if (i === -1) throw new Error(`no pending request for ${match}`);
const [req] = pending.splice(i, 1);
await act(async () => req.resolve(body));
};
return { fetchMock, answer };
}
const asset = (id: string, name: string) => ({
id,
originalFileName: name,
fileCreatedAt: '2024-05-17T10:00:00.000Z',
isFavorite: false,
});
let net: ReturnType<typeof deferredFetch>;
beforeEach(() => {
net = deferredFetch();
vi.stubGlobal('fetch', net.fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('AssetPicker', () => {
it('shows the tab it is on when an older request answers last', async () => {
render(<AssetPicker onSelect={() => {}} onClose={() => {}} />);
// Opens on Favorites; the operator switches to All Photos before it loads.
fireEvent.click(screen.getByRole('button', { name: /All Photos/ }));
await net.answer('favorites=false', { assets: [asset('a1', 'all.jpg')], nextPage: null });
await net.answer('favorites=true', { assets: [asset('f1', 'fav.jpg')], nextPage: null });
expect(screen.getByRole('button', { name: /all\.jpg/ })).toBeTruthy();
expect(screen.queryByRole('button', { name: /fav\.jpg/ })).toBeNull();
});
it('keeps the spinner until the current request answers', async () => {
render(<AssetPicker onSelect={() => {}} onClose={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: /All Photos/ }));
// The stale Favorites answer must not end the All Photos loading state.
await net.answer('favorites=true', { assets: [asset('f1', 'fav.jpg')], nextPage: null });
expect(screen.queryByRole('button', { name: /fav\.jpg/ })).toBeNull();
expect(screen.queryByText(/No favorite photos found/)).toBeNull();
await net.answer('favorites=false', { assets: [asset('a1', 'all.jpg')], nextPage: null });
expect(screen.getByRole('button', { name: /all\.jpg/ })).toBeTruthy();
});
});

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import { ConfirmProvider, useConfirm } from '../components/ConfirmDialog';
import { ConfirmProvider, useChoice, useConfirm } from '../components/ConfirmDialog';
afterEach(cleanup);
@@ -60,3 +60,46 @@ describe('ConfirmDialog', () => {
await waitFor(() => expect(results).toEqual([false, false]));
});
});
/** The concurrent-edit prompt (#601): more than two ways out. */
function ChoiceTrigger({ onResult }: { onResult: (id: string | null) => void }) {
const choose = useChoice();
return (
<button
onClick={async () =>
onResult(
await choose({
title: 'Changed elsewhere',
actions: [
{ id: 'overwrite', label: 'Overwrite anyway' },
{ id: 'reload', label: 'Reload (discard mine)' },
],
cancelLabel: 'Keep editing',
}),
)
}
>
choose
</button>
);
}
describe('useChoice', () => {
it('resolves to the picked action, or null on cancel', async () => {
const results: (string | null)[] = [];
render(
<ConfirmProvider>
<ChoiceTrigger onResult={(id) => results.push(id)} />
</ConfirmProvider>,
);
fireEvent.click(screen.getByText('choose'));
fireEvent.click(screen.getByRole('button', { name: 'Reload (discard mine)' }));
await waitFor(() => expect(results).toEqual(['reload']));
fireEvent.click(screen.getByText('choose'));
fireEvent.click(screen.getByRole('button', { name: 'Overwrite anyway' }));
await waitFor(() => expect(results).toEqual(['reload', 'overwrite']));
fireEvent.click(screen.getByText('choose'));
fireEvent.click(screen.getByRole('button', { name: 'Keep editing' }));
await waitFor(() => expect(results).toEqual(['reload', 'overwrite', null]));
});
});

View File

@@ -0,0 +1,57 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import NewPageDialog from '../components/page-builder/NewPageDialog';
afterEach(cleanup);
const taken = { subpages: [], albums: [], journal: [] };
function setup() {
const onCreate = vi.fn();
render(
<NewPageDialog
taken={taken}
existingPageSlugs={[]}
creating={false}
onCreate={onCreate}
onClose={() => {}}
/>,
);
return { onCreate, slug: screen.getByLabelText('URL') as HTMLInputElement };
}
/** Type one key at a time into a controlled input, as a person does. */
function typeKeys(input: HTMLInputElement, text: string) {
for (const ch of text) fireEvent.change(input, { target: { value: input.value + ch } });
}
describe('NewPageDialog slug field', () => {
it('takes a hyphen typed between two words', () => {
const { slug, onCreate } = setup();
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'About' } });
// Select all and retype: the first key replaces the derived slug.
fireEvent.change(slug, { target: { value: 'a' } });
typeKeys(slug, 'bout-us');
expect(slug.value).toBe('about-us');
fireEvent.click(screen.getByRole('button', { name: 'Create page' }));
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ slug: 'about-us' }));
});
it('creates the finished slug when the field ends in a hyphen', () => {
const { slug, onCreate } = setup();
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Pricing' } });
typeKeys(slug, '-');
expect(slug.value).toBe('pricing-');
fireEvent.click(screen.getByRole('button', { name: 'Create page' }));
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ slug: 'pricing' }));
});
it('derives a readable slug from a German title', () => {
const { slug } = setup();
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Preise für Paare' } });
expect(slug.value).toBe('preise-fur-paare');
});
});

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { lastDays } from '../components/analyticsDays';
/**
* "Last 14 Days" used to show the last 14 days that had any views, so quiet
* days vanished and the chart could span a month (QA A-16).
*/
describe('lastDays', () => {
const days = {
'2026-08-28': { pageviews: 4 },
'2026-09-20': { pageviews: 6 },
'2026-09-29': { pageviews: 16 },
};
it('returns every calendar day up to today, with 0 for days without views', () => {
const out = lastDays(days, 14, new Date('2026-09-29T12:00:00Z'));
expect(out).toHaveLength(14);
expect(out[0]).toEqual({ date: '2026-09-16', pageviews: 0 });
expect(out[4]).toEqual({ date: '2026-09-20', pageviews: 6 });
expect(out[13]).toEqual({ date: '2026-09-29', pageviews: 16 });
// Older recorded days are outside the window, not pulled into it.
expect(out.find((d) => d.date === '2026-08-28')).toBeUndefined();
});
it('counts days in UTC, the way the tracker keys them', () => {
// 23:30 UTC on the 29th is already the 30th in Berlin.
const out = lastDays(days, 2, new Date('2026-09-29T23:30:00Z'));
expect(out.map((d) => d.date)).toEqual(['2026-09-28', '2026-09-29']);
});
it('crosses a month boundary without skipping or repeating a day', () => {
const out = lastDays({}, 3, new Date('2026-10-01T00:00:00Z'));
expect(out.map((d) => d.date)).toEqual(['2026-09-29', '2026-09-30', '2026-10-01']);
});
});

View File

@@ -110,3 +110,67 @@ describe('reading', () => {
expect(ops.followMovedIndex(open, from, to)).toBe(expected);
});
});
describe('menu with content pages (#722)', () => {
const withPages = (): GalleryState => ({
...state(),
// pricing before Japan, faq between Trips and Korea.
pageRefs: [
{ slug: 'pricing', position: 0 },
{ slug: 'faq', position: 2 },
],
});
const labels = (g: GalleryState) =>
ops.menuItems(g).map((i) => (i.kind === 'page' ? `page:${i.slug}` : g.subpages[i.index].name));
it('interleaves pages and subpages', () => {
expect(labels(withPages())).toEqual(['page:pricing', 'Japan', 'Trips', 'page:faq', 'Korea']);
});
it('moves a page between subpages', () => {
const g = ops.moveMenuItem(withPages(), 0, 2);
expect(labels(g)).toEqual(['Japan', 'Trips', 'page:pricing', 'page:faq', 'Korea']);
});
it('moves a subpage past a page and follows the open sheet', () => {
const before = withPages();
// Japan (row 1) to the end (row 4).
expect(ops.followMenuMove(before, 0, 1, 4)).toBe(2);
const g = ops.moveMenuItem(before, 1, 4);
expect(labels(g)).toEqual(['page:pricing', 'Trips', 'page:faq', 'Korea', 'Japan']);
expect(g.subpages.map((s) => s.name)).toEqual(['Trips', 'Korea', 'Japan']);
});
it('puts a page in the menu at the end, or before a row, and takes it out', () => {
let g = ops.setPageInMenu(state(), 'about-me', true);
expect(labels(g)).toEqual(['Japan', 'Trips', 'Korea', 'page:about-me']);
g = ops.setPageInMenu(g, 'rates', true, 1);
expect(labels(g)).toEqual(['Japan', 'page:rates', 'Trips', 'Korea', 'page:about-me']);
g = ops.setPageInMenu(g, 'rates', false);
expect(labels(g)).toEqual(['Japan', 'Trips', 'Korea', 'page:about-me']);
expect(ops.isPageInMenu(g, 'rates')).toBe(false);
});
it('keeps pages in place when a subpage is removed', () => {
const g = ops.removeSubpage(withPages(), 0);
expect(labels(g)).toEqual(['page:pricing', 'Trips', 'page:faq', 'Korea']);
});
it('follows a rename', () => {
const g = ops.renamePageRef(withPages(), 'faq', 'questions');
expect(labels(g)).toContain('page:questions');
expect(labels(g)).not.toContain('page:faq');
});
});
describe('addHeroes (#602)', () => {
it('appends a multi-pick in pick order, skipping duplicates', () => {
expect(ops.addHeroes(state(), ['h9', 'h2', 'h8', 'h9']).hero).toEqual([
'h1',
'h2',
'h3',
'h9',
'h8',
]);
});
});

View File

@@ -160,3 +160,29 @@ describe('subpage keys', () => {
});
});
});
describe('content page references (#722)', () => {
it('keeps `- page:` entries where they were among the subpages', () => {
const yaml = {
subpages: [
{ page: 'pricing' },
{ name: 'Japan', albums: [A] },
{ page: 'faq' },
{ name: 'Korea', albums: [B] },
{ page: 'contact-me' },
],
};
expect(roundTrip(yaml)).toEqual(yaml);
});
it('parses page references apart from the subpages', () => {
const g = parseGalleryYaml({ subpages: [{ name: 'Japan', albums: [A] }, { page: 'faq' }] });
expect(g.subpages.map((s) => s.name)).toEqual(['Japan']);
expect(g.pageRefs).toEqual([{ slug: 'faq', position: 1 }]);
});
it('writes a menu of pages only', () => {
const yaml = { subpages: [{ page: 'pricing' }] };
expect(roundTrip(yaml)).toEqual(yaml);
});
});

View File

@@ -1,7 +1,13 @@
import { describe, it, expect } from 'vitest';
import type { JournalBlock } from '@/lib/journal';
import { serializeJournalMarkdown, parseJournalMarkdown } from '@/lib/journal';
import { createBlock, moveBlock, isLegacyAssetRef } from '../components/journal/blockOps';
import {
createBlock,
createPhotoBlocks,
fillAssetSlots,
moveBlock,
isLegacyAssetRef,
} from '../components/journal/blockOps';
import {
clampSplit,
splitFromPointer,
@@ -124,3 +130,21 @@ describe('split pane', () => {
expect(parseStoredSplit('abc')).toBeNull();
});
});
describe('multi-pick into photo blocks (#602)', () => {
it('fills empty slots first, then appends', () => {
expect(fillAssetSlots(['a', '', ''], ['x', 'y', 'z'])).toEqual(['a', 'x', 'y', 'z']);
});
it('never grows past a capacity', () => {
expect(fillAssetSlots(['', ''], ['x', 'y', 'z'], 2)).toEqual(['x', 'y']);
expect(fillAssetSlots(['a', ''], ['x'], 2)).toEqual(['a', 'x']);
});
it('creates one contained photo block per asset', () => {
expect(createPhotoBlocks(['x', 'y'])).toEqual([
{ type: 'photo', assetId: 'x', caption: '', layout: 'contained' },
{ type: 'photo', assetId: 'y', caption: '', layout: 'contained' },
]);
});
});

View File

@@ -0,0 +1,24 @@
import { readFileSync } from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
// `?page=<slug>` in /admin/pages selects a page, and the page panel turns the
// slug into its "open live page" link. A slug such as `/evil.example` would
// make that link `//evil.example`, which leaves the site. Both ends guard it.
const read = (file: string) => readFileSync(path.join(process.cwd(), file), 'utf8');
describe('page link from ?page=', () => {
it('only selects a page for a valid slug', () => {
const src = read('app/admin/components/PageBuilder.tsx');
const fn = src.slice(src.indexOf('function openPageFromLink'));
const body = fn.slice(0, fn.indexOf('\n }\n'));
expect(body).toMatch(/if \(!isValidSlug\(slug\)\) return;/);
expect(body.indexOf('isValidSlug(slug)')).toBeLessThan(body.indexOf('setSelectedPage(slug)'));
});
it('encodes the slug in the live-page link', () => {
const src = read('app/admin/components/page-builder/PagePanel.tsx');
expect(src).toContain('href={`/${encodeURIComponent(slug)}`}');
expect(src).not.toContain('href={`/${slug}`}');
});
});

View File

@@ -50,9 +50,9 @@ describe('systemHealth', () => {
});
});
it('reports a doctor warning as neutral', () => {
it('reports a doctor warning in the warning tone, not the neutral one', () => {
expect(health({ doctorLevel: 'warn' })).toEqual({
tone: 'unknown',
tone: 'warning',
label: 'Check Diagnostics',
});
});
@@ -67,7 +67,7 @@ describe('systemHealth', () => {
'Setup Incomplete': 'unknown',
'System Degraded': 'disconnected',
'Needs Attention': 'disconnected',
'Check Diagnostics': 'unknown',
'Check Diagnostics': 'warning',
'System OK': 'connected',
'Status Unknown': 'unknown',
};

View File

@@ -34,4 +34,5 @@
@import './styles/diagnostics.css';
@import './styles/messages.css';
@import './styles/notifications.css';
@import './styles/proofing.css';
@import './styles/redesign.css';

View File

@@ -47,7 +47,7 @@ const NAV: {
{
label: 'Pages',
href: '/admin/pages',
match: /^\/admin\/pages$/,
match: /^\/admin\/pages(\/|$)/,
icon: <Icons.IconGrid size={16} />,
},
{
@@ -68,6 +68,12 @@ const NAV: {
icon: <Icons.IconFileText size={16} />,
badge: 'messages',
},
{
label: 'Proofing',
href: '/admin/proofing',
match: /^\/admin\/proofing/,
icon: <Icons.IconHeart size={16} />,
},
{
label: 'Analytics',
href: '/admin/analytics',
@@ -277,6 +283,9 @@ export default function AdminDashboard({ onLogout, children }: Props) {
menuOpen ? ' menu-open' : ''
}`}
>
<a href="#admin-main" className="skip-link">
Skip to content
</a>
<aside className="admin-sidebar" aria-label="Admin">
<button
type="button"
@@ -506,7 +515,9 @@ export default function AdminDashboard({ onLogout, children }: Props) {
</div>
</aside>
<main className="admin-main admin-workspace">{children}</main>
<main id="admin-main" tabIndex={-1} className="admin-main admin-workspace">
{children}
</main>
<BackupManagerModal
isOpen={showBackupModal}

View File

@@ -15,9 +15,11 @@ import * as Icons from './Icons';
import { adminGet } from './useAdminFetch';
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
import { DOCTOR_LEVEL_EVENT } from './systemHealth';
import { parseGalleryYaml } from './page-builder/galleryYaml';
interface OverviewData {
gallery: { hero?: unknown; subpages?: Array<{ enabled?: boolean }> } | null;
/** gallery.yaml as stored; `subpages` may be a list or the older name → albums map. */
gallery: Record<string, unknown> | null;
journal: Array<{ frontmatter: { draft?: boolean } }> | null;
messages: Array<{ read: boolean; name: string }> | null;
analytics: { days?: Record<string, { pageviews?: number }> } | null;
@@ -32,6 +34,7 @@ const FIX_ROUTE: Record<string, string> = {
passwords: '/admin/settings/security',
'album-ids': '/admin/pages',
'albums-shared': '/admin/pages',
'content-pages': '/admin/pages',
};
function viewsLastDays(
@@ -85,7 +88,9 @@ export default function AdminOverview() {
};
}, []);
const subpages = data?.gallery?.subpages ?? [];
// Read the way the page builder reads it: both shapes of `subpages:`, and
// `- page:` entries, which are content pages in the menu, not subpages (#722).
const subpages = data?.gallery ? parseGalleryYaml(data.gallery).subpages : [];
const published = subpages.filter((sp) => sp.enabled !== false).length;
const heroRaw = data?.gallery?.hero;
const heroCount = Array.isArray(heroRaw) ? heroRaw.length : heroRaw ? 1 : 0;

View File

@@ -52,7 +52,7 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
>
<div className="picker-header">
<h3 id="album-picker-title">Select Album</h3>
<button className="admin-btn-icon" onClick={onClose}>
<button type="button" className="admin-btn-icon" onClick={onClose} aria-label="Close">
×
</button>
</div>
@@ -76,12 +76,16 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
{filtered.map((album) => {
const isUsed = usedAlbumIds.has(album.id);
return (
<div
// A button, not a div: the list has to work from the keyboard.
// An album already on the site is disabled rather than inert.
<button
type="button"
key={album.id}
className={`picker-item ${isUsed ? 'used' : ''}`}
onClick={() => !isUsed && onSelect(album.id)}
disabled={isUsed}
onClick={() => onSelect(album.id)}
>
<div className="picker-item-thumb">
<span className="picker-item-thumb">
{album.thumbnailAssetId ? (
<img
className="picker-thumb-img"
@@ -90,12 +94,12 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
loading="lazy"
/>
) : (
<div className="picker-thumb-placeholder">
<span className="picker-thumb-placeholder">
<IconFolder size={20} />
</div>
</span>
)}
</div>
<div className="picker-item-info">
</span>
<span className="picker-item-info">
<span className="picker-item-name">{album.albumName}</span>
<span className="picker-item-meta">
{album.assetCount} photos
@@ -108,15 +112,15 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
{album.shared === false && (
<span className="picker-item-note">Not shared in Immich</span>
)}
</div>
<div className="picker-item-action">
</span>
<span className="picker-item-action">
{isUsed ? (
<span className="picker-used-badge">In use</span>
) : (
<span className="picker-select-badge">Select</span>
)}
</div>
</div>
</span>
</button>
);
})}
</div>

View File

@@ -4,6 +4,7 @@ import * as Icons from './Icons';
import { useAdminFetch } from './useAdminFetch';
import AdminLoadState from './AdminLoadState';
import PageHeader from './PageHeader';
import { lastDays } from './analyticsDays';
interface AnalyticsData {
trackingEnabled?: boolean;
@@ -40,10 +41,9 @@ export default function AnalyticsView() {
const totalViews = data.summary?.totalViews || 0;
const maxPageViews = data.topPages[0]?.count || 1;
const daysList = Object.entries(data.days || {})
.sort((a, b) => a[0].localeCompare(b[0]))
.slice(-14); // Last 14 days
const maxDayViews = Math.max(...daysList.map(([, d]) => d.pageviews || 0), 1);
const hasDays = Object.keys(data.days || {}).length > 0;
const daysList = lastDays(data.days || {}, 14, new Date());
const maxDayViews = Math.max(...daysList.map((d) => d.pageviews), 1);
const desktopRatio =
data.devices.desktop + data.devices.mobile > 0
@@ -105,24 +105,24 @@ export default function AnalyticsView() {
<Icons.IconGrid size={16} /> Daily Traffic Trend (Last 14 Days)
</h3>
</div>
{daysList.length === 0 ? (
{!hasDays ? (
<p className="analytics-empty">
No pageviews recorded yet. Visit portfolio pages to start tracking!
</p>
) : (
<div className="analytics-bar-chart">
{daysList.map(([dateStr, dayData]) => {
const heightPct = Math.round((dayData.pageviews / maxDayViews) * 100);
const formattedDate = dateStr.slice(5); // MM-DD
{daysList.map(({ date, pageviews }) => {
const heightPct = Math.round((pageviews / maxDayViews) * 100);
const formattedDate = date.slice(5); // MM-DD
return (
<div
key={dateStr}
className="bar-col"
title={`${dateStr}: ${dayData.pageviews} views`}
>
<div key={date} className="bar-col" title={`${date}: ${pageviews} views`}>
<div className="bar-wrapper">
<div className="bar-fill" style={{ height: `${Math.max(heightPct, 8)}%` }}>
<span className="bar-val">{dayData.pageviews}</span>
{/* A day with views keeps a sliver of bar; an empty one has none. */}
<div
className="bar-fill"
style={{ height: `${pageviews > 0 ? Math.max(heightPct, 3) : 0}%` }}
>
<span className="bar-val">{pageviews}</span>
</div>
</div>
<span className="bar-label">{formattedDate}</span>

View File

@@ -19,6 +19,7 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useScrollLock } from './useScrollLock';
import { useModalDialog } from '@/hooks/useModalDialog';
interface AssetInfo {
id: string;
@@ -65,8 +66,10 @@ function SortableAssetTile({
<img src={`/api/admin/thumbnail/${asset.id}`} alt={asset.originalFileName} loading="lazy" />
<span className="order-tile-index">{index + 1}</span>
<button
type="button"
className="order-tile-action"
title="Unpin — let this photo follow automatically"
aria-label={`Unpin ${asset.originalFileName}`}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
@@ -94,6 +97,7 @@ export default function AssetOrderEditor({
onClose,
}: Props) {
useScrollLock(true);
const cardRef = useModalDialog(onClose);
const [assets, setAssets] = useState<AssetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -164,10 +168,17 @@ export default function AssetOrderEditor({
return (
<div className="picker-overlay" onClick={onClose}>
<div className="asset-picker-modal" onClick={(e) => e.stopPropagation()}>
<div
className="asset-picker-modal"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="order-editor-title"
onClick={(e) => e.stopPropagation()}
>
<div className="picker-header">
<h3>Photo order — {albumName}</h3>
<button className="admin-btn-icon" onClick={onClose}>
<h3 id="order-editor-title">Photo order — {albumName}</h3>
<button type="button" className="admin-btn-icon" onClick={onClose} aria-label="Close">
×
</button>
</div>
@@ -240,19 +251,19 @@ export default function AssetOrderEditor({
</p>
<div className="order-editor-grid">
{unpinned.map((asset) => (
<div
<button
type="button"
key={asset.id}
className="order-tile order-tile-unpinned"
title={asset.originalFileName}
aria-label={`Pin ${asset.originalFileName}`}
onClick={() => setPinnedIds([...pinnedIds, asset.id])}
>
<img
src={`/api/admin/thumbnail/${asset.id}`}
alt={asset.originalFileName}
loading="lazy"
/>
<span className="order-tile-action">+</span>
</div>
<img src={`/api/admin/thumbnail/${asset.id}`} alt="" loading="lazy" />
<span className="order-tile-action" aria-hidden="true">
+
</span>
</button>
))}
</div>
</div>

View File

@@ -1,8 +1,9 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useModalDialog } from '@/hooks/useModalDialog';
import { IconCheck, IconFolder, IconImage, IconStar } from './Icons';
import { canSelectMore, matchesQuery, toggleSelection } from '@/lib/admin/assetSelection';
import { IconCheck, IconFolder, IconImage, IconSearch, IconStar } from './Icons';
import { useScrollLock } from './useScrollLock';
interface AssetInfo {
@@ -13,7 +14,15 @@ interface AssetInfo {
}
interface Props {
onSelect: (assetId: string) => void;
/** Single-select: called with the one asset picked; the caller closes the picker. */
onSelect?: (assetId: string) => void;
/**
* Multi-select (#602): tiles toggle, and a confirm button hands over every
* picked asset in the order they were picked. Takes precedence over `onSelect`.
*/
onSelectMany?: (assetIds: string[]) => void;
/** Most assets `onSelectMany` accepts; unlimited when omitted. */
max?: number;
onClose: () => void;
currentAssetIds?: string[];
albumId?: string;
@@ -22,8 +31,12 @@ interface Props {
type Tab = 'album' | 'favorites' | 'all';
const SEARCH_DEBOUNCE_MS = 300;
export default function AssetPicker({
onSelect,
onSelectMany,
max,
onClose,
currentAssetIds = [],
albumId,
@@ -31,21 +44,50 @@ export default function AssetPicker({
}: Props) {
useScrollLock(true);
const cardRef = useModalDialog(onClose);
const multi = onSelectMany !== undefined;
const [tab, setTab] = useState<Tab>(albumId ? 'album' : 'favorites');
const [assets, setAssets] = useState<AssetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [queryInput, setQueryInput] = useState('');
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<string[]>([]);
const [uuidInput, setUuidInput] = useState('');
const [uuidError, setUuidError] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const currentIds = new Set(currentAssetIds);
/**
* Bumped each time the list starts over (tab, album or search changed). A
* request is only allowed to touch the list while its generation is current:
* without this, a slow Favorites answer landing after a switch to All Photos
* replaced that tab's photos, and ended its spinner early.
*/
const generation = useRef(0);
const currentIds = useMemo(() => new Set(currentAssetIds), [currentAssetIds]);
// Debounce typing into the query the fetch runs on.
useEffect(() => {
const t = setTimeout(() => setQuery(queryInput.trim()), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(t);
}, [queryInput]);
// The album tab loads the album whole and filters locally; the library tabs
// search server-side, since they are paged.
const serverQuery = tab === 'album' ? '' : query;
const loadAssets = useCallback(
async (pageNum: number, append: boolean = false) => {
if (pageNum === 1) setLoading(true);
else setLoadingMore(true);
// A fresh list starts a generation; a further page belongs to the current one.
const gen = append ? generation.current : ++generation.current;
const current = () => gen === generation.current;
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
// A "load more" of the previous list will not clear this itself.
setLoadingMore(false);
}
try {
let res: Response;
@@ -56,22 +98,31 @@ export default function AssetPicker({
page: pageNum.toString(),
favorites: tab === 'favorites' ? 'true' : 'false',
});
if (serverQuery) params.set('q', serverQuery);
res = await fetch(`/api/admin/assets?${params}`);
}
if (res.ok) {
const data = await res.json();
setAssets((prev) => (append ? [...prev, ...data.assets] : data.assets));
if (!current()) return;
setAssets((prev) => {
if (!append) return data.assets;
// Merged filename + description pages can overlap across pages.
const seen = new Set(prev.map((a) => a.id));
return [...prev, ...data.assets.filter((a: AssetInfo) => !seen.has(a.id))];
});
setHasMore(data.nextPage !== null);
setPage(pageNum);
}
} catch (err) {
console.error('Failed to load assets:', err);
if (current()) console.error('Failed to load assets:', err);
} finally {
setLoading(false);
setLoadingMore(false);
if (current()) {
setLoading(false);
setLoadingMore(false);
}
}
},
[tab, albumId],
[tab, albumId, serverQuery],
);
useEffect(() => {
@@ -79,7 +130,9 @@ export default function AssetPicker({
setPage(1);
setHasMore(false);
loadAssets(1);
}, [tab, loadAssets]);
}, [loadAssets]);
const visible = tab === 'album' ? assets.filter((a) => matchesQuery(a, query)) : assets;
// Infinite scroll
function handleScroll() {
@@ -90,6 +143,16 @@ export default function AssetPicker({
}
}
function pick(id: string) {
if (currentIds.has(id)) return;
if (multi) setSelected((s) => toggleSelection(s, id, { disabled: currentIds, max }));
else onSelect?.(id);
}
function confirm() {
if (multi && selected.length > 0) onSelectMany(selected);
}
function handleUuidSubmit(e: React.FormEvent) {
e.preventDefault();
const uuid = uuidInput.trim();
@@ -99,7 +162,12 @@ export default function AssetPicker({
return;
}
setUuidError('');
onSelect(uuid);
if (multi) {
setSelected((s) => toggleSelection(s, uuid.toLowerCase(), { disabled: currentIds, max }));
setUuidInput('');
} else {
onSelect?.(uuid);
}
}
function formatDate(dateStr: string): string {
@@ -114,6 +182,8 @@ export default function AssetPicker({
}
}
const full = multi && !canSelectMore(selected, max);
return (
<div className="picker-overlay" onClick={onClose}>
<div
@@ -126,7 +196,7 @@ export default function AssetPicker({
>
<div className="picker-header">
<h3 id="asset-picker-title">{title || 'Select Hero Image'}</h3>
<button className="admin-btn-icon" onClick={onClose}>
<button className="admin-btn-icon" onClick={onClose} aria-label="Close">
×
</button>
</div>
@@ -136,6 +206,7 @@ export default function AssetPicker({
{albumId && (
<button
className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`}
aria-pressed={tab === 'album'}
onClick={() => setTab('album')}
>
<IconFolder size={14} />
@@ -144,6 +215,7 @@ export default function AssetPicker({
)}
<button
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
aria-pressed={tab === 'favorites'}
onClick={() => setTab('favorites')}
>
<IconStar size={14} />
@@ -151,6 +223,7 @@ export default function AssetPicker({
</button>
<button
className={`asset-picker-tab ${tab === 'all' ? 'active' : ''}`}
aria-pressed={tab === 'all'}
onClick={() => setTab('all')}
>
<IconImage size={14} />
@@ -158,45 +231,91 @@ export default function AssetPicker({
</button>
</div>
{/* Search */}
<div className="asset-picker-search">
<IconSearch size={14} />
<input
type="search"
value={queryInput}
onChange={(e) => setQueryInput(e.target.value)}
onKeyDown={(e) => {
// Enter in the field searches at once instead of submitting anything.
if (e.key === 'Enter') {
e.preventDefault();
setQuery(queryInput.trim());
}
}}
maxLength={100}
placeholder={
tab === 'album'
? 'Filter this album by filename…'
: 'Search filename, description, or a date (2024-05)…'
}
aria-label="Search photos"
className="asset-picker-search-input"
/>
</div>
{/* Asset Grid */}
<div className="asset-picker-grid" ref={listRef} onScroll={handleScroll}>
<div
className="asset-picker-grid"
ref={listRef}
onScroll={handleScroll}
role="group"
aria-label="Photos"
>
{loading ? (
<div className="asset-picker-loading">
<div className="admin-spinner" />
</div>
) : assets.length === 0 ? (
) : visible.length === 0 ? (
<p className="empty-hint">
{tab === 'favorites'
? 'No favorite photos found. Star photos in Immich first, or switch to "All Photos".'
: 'No photos found.'}
{query
? `No photos match “${query}”.`
: tab === 'favorites'
? 'No favorite photos found. Star photos in Immich first, or switch to "All Photos".'
: 'No photos found.'}
</p>
) : (
<>
{assets.map((asset) => {
{visible.map((asset) => {
const isUsed = currentIds.has(asset.id);
const order = selected.indexOf(asset.id);
const isSelected = order !== -1;
const blocked = isUsed || (full && !isSelected);
const label = `${asset.originalFileName}, ${formatDate(asset.fileCreatedAt)}${
isUsed ? ' (already used)' : ''
}`;
return (
<div
<button
type="button"
key={asset.id}
className={`asset-picker-tile ${isUsed ? 'used' : ''}`}
onClick={() => !isUsed && onSelect(asset.id)}
className={`asset-picker-tile ${isUsed ? 'used' : ''} ${
isSelected ? 'selected' : ''
}`}
onClick={() => pick(asset.id)}
disabled={blocked}
aria-pressed={multi ? isSelected : undefined}
aria-label={label}
title={`${asset.originalFileName}\n${formatDate(asset.fileCreatedAt)}`}
>
<img
src={`/api/admin/thumbnail/${asset.id}`}
alt={asset.originalFileName}
loading="lazy"
/>
<img src={`/api/admin/thumbnail/${asset.id}`} alt="" loading="lazy" />
{isUsed && (
<div className="asset-picker-used-badge">
<span className="asset-picker-used-badge" aria-hidden="true">
<IconCheck size={11} />
</div>
</span>
)}
{isSelected && (
<span className="asset-picker-order-badge" aria-hidden="true">
{max === 1 ? <IconCheck size={11} /> : order + 1}
</span>
)}
{asset.isFavorite && !isUsed && (
<div className="asset-picker-fav-badge" title="Favorite in Immich">
<span className="asset-picker-fav-badge" aria-hidden="true">
<IconStar size={14} />
</div>
</span>
)}
</div>
</button>
);
})}
{loadingMore && (
@@ -208,6 +327,36 @@ export default function AssetPicker({
)}
</div>
{/* Selection bar (multi-select) */}
{multi && (
<div className="asset-picker-selection">
<span aria-live="polite">
{selected.length === 0
? max && max > 1
? `Pick up to ${max} photos`
: 'Pick photos'
: `${selected.length}${max && max > 1 ? ` of ${max}` : ''} selected`}
</span>
{selected.length > 0 && (
<button
type="button"
className="admin-btn admin-btn-sm"
onClick={() => setSelected([])}
>
Clear
</button>
)}
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-primary"
disabled={selected.length === 0}
onClick={confirm}
>
{selected.length > 1 ? `Add ${selected.length} photos` : 'Add photo'}
</button>
</div>
)}
{/* UUID Fallback */}
<div className="asset-picker-uuid-section">
<form onSubmit={handleUuidSubmit} className="asset-picker-uuid-form">
@@ -219,6 +368,7 @@ export default function AssetPicker({
setUuidError('');
}}
placeholder="Or paste asset UUID directly..."
aria-label="Asset UUID"
className="asset-picker-uuid-input"
/>
<button type="submit" className="admin-btn admin-btn-sm" disabled={!uuidInput.trim()}>

View File

@@ -20,6 +20,7 @@ const TABS: { target: BackupTarget; label: string; file: string }[] = [
{ target: 'about', label: 'About', file: 'about.md' },
{ target: 'privacy', label: 'Privacy', file: 'privacy.md' },
{ target: 'journal', label: 'Journal', file: 'journal entries' },
{ target: 'pages', label: 'Content pages', file: 'content pages' },
];
type BackupLists = Record<BackupTarget, BackupItem[]>;
@@ -30,6 +31,7 @@ const EMPTY_LISTS: BackupLists = {
about: [],
privacy: [],
journal: [],
pages: [],
};
export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) {
@@ -87,7 +89,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
setSuccessMsg(
item.slug
? `Journal entry "${item.slug}" restored.`
? `${item.target === 'pages' ? 'Page' : 'Journal entry'} "${item.slug}" restored.`
: `Backup restored successfully! (${filename})`,
);
setConfirmItem(null);
@@ -121,8 +123,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
<div>
<h2 id="backup-modal-title">Backup History &amp; Restoration</h2>
<p className="backup-modal-subtitle">
Restore an earlier version of your pages, settings, about page or journal — including
deleted journal entries.
Restore an earlier version of your pages, settings, about page, journal or content
pages — including deleted journal entries and pages.
</p>
</div>
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
@@ -194,7 +196,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
const dateObj = item.timestamp ? new Date(item.timestamp) : null;
const formattedDate =
dateObj && !isNaN(dateObj.getTime())
? dateObj.toLocaleString('de-DE', {
? dateObj.toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'medium',
})
@@ -251,8 +253,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
}}
/>
{showAllBackups
? 'Ältere Einträge einklappen'
: `Ältere Einträge anzeigen (${hiddenCount} weitere)`}
? 'Hide older backups'
: `Show ${hiddenCount} older ${hiddenCount === 1 ? 'backup' : 'backups'}`}
</button>
)}
</div>

View File

@@ -28,37 +28,90 @@ export interface ConfirmOptions {
type Confirm = (options: ConfirmOptions) => Promise<boolean>;
/** Outside the provider (a component tested on its own) it falls back to window.confirm. */
const ConfirmContext = createContext<Confirm>(async (o) =>
typeof window === 'undefined' ? false : window.confirm(o.title),
);
export function useConfirm(): Confirm {
return useContext(ConfirmContext);
/** One button of a choice; `id` is what the promise resolves to. */
export interface ChoiceAction<Id extends string> {
id: Id;
label: string;
danger?: boolean;
}
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<ConfirmOptions | null>(null);
const resolver = useRef<((ok: boolean) => void) | null>(null);
/**
* More than yes or no — the concurrent-edit prompt (#601) offers three ways
* out. The cancel button (and Esc, and the backdrop) resolves to null.
*/
export interface ChoiceOptions<Id extends string> {
title: string;
message?: ReactNode;
/** Left to right; the last one is the primary button. */
actions: ChoiceAction<Id>[];
cancelLabel?: string;
}
const confirm = useCallback<Confirm>(
(options) =>
new Promise<boolean>((resolve) => {
resolver.current?.(false);
type Choose = <Id extends string>(options: ChoiceOptions<Id>) => Promise<Id | null>;
interface DialogContext {
confirm: Confirm;
choose: Choose;
}
/** Outside the provider (a component tested on its own) it falls back to window.confirm. */
const ConfirmContext = createContext<DialogContext>({
confirm: async (o) => (typeof window === 'undefined' ? false : window.confirm(o.title)),
choose: async (o) =>
typeof window !== 'undefined' && window.confirm(o.title)
? (o.actions[o.actions.length - 1]?.id ?? null)
: null,
});
export function useConfirm(): Confirm {
return useContext(ConfirmContext).confirm;
}
export function useChoice(): Choose {
return useContext(ConfirmContext).choose;
}
const CONFIRM_ID = 'confirm';
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<ChoiceOptions<string> | null>(null);
const resolver = useRef<((id: string | null) => void) | null>(null);
const choose = useCallback(
(options: ChoiceOptions<string>) =>
new Promise<string | null>((resolve) => {
resolver.current?.(null);
resolver.current = resolve;
setPending(options);
}),
[],
) as Choose;
const confirm = useCallback<Confirm>(
async (options) =>
(await choose({
title: options.title,
message: options.message,
cancelLabel: options.cancelLabel,
actions: [
{
id: CONFIRM_ID,
label: options.confirmLabel ?? 'Confirm',
danger: options.danger,
},
],
})) === CONFIRM_ID,
[choose],
);
const settle = useCallback((ok: boolean) => {
resolver.current?.(ok);
const settle = useCallback((id: string | null) => {
resolver.current?.(id);
resolver.current = null;
setPending(null);
}, []);
return (
<ConfirmContext.Provider value={confirm}>
<ConfirmContext.Provider value={{ confirm, choose }}>
{children}
{pending && <Dialog options={pending} onSettle={settle} />}
</ConfirmContext.Provider>
@@ -69,13 +122,14 @@ function Dialog({
options,
onSettle,
}: {
options: ConfirmOptions;
onSettle: (ok: boolean) => void;
options: ChoiceOptions<string>;
onSettle: (id: string | null) => void;
}) {
// Esc cancels, focus is trapped and returned (the same hook the modals use).
const cardRef = useModalDialog(() => onSettle(false));
const cardRef = useModalDialog(() => onSettle(null));
const last = options.actions.length - 1;
return (
<div className="confirm-backdrop" onClick={() => onSettle(false)}>
<div className="confirm-backdrop" onClick={() => onSettle(null)}>
<div
ref={cardRef}
className="confirm-card"
@@ -97,17 +151,26 @@ function Dialog({
<button
type="button"
className="admin-btn admin-btn-ghost"
onClick={() => onSettle(false)}
onClick={() => onSettle(null)}
>
{options.cancelLabel ?? 'Cancel'}
</button>
<button
type="button"
className={`admin-btn ${options.danger ? 'confirm-danger' : 'admin-btn-primary'}`}
onClick={() => onSettle(true)}
>
{options.confirmLabel ?? 'Confirm'}
</button>
{options.actions.map((action, i) => (
<button
key={action.id}
type="button"
className={`admin-btn ${
action.danger
? 'confirm-danger'
: i === last
? 'admin-btn-primary'
: 'admin-btn-ghost'
}`}
onClick={() => onSettle(action.id)}
>
{action.label}
</button>
))}
</div>
</div>
</div>

View File

@@ -45,7 +45,16 @@ const GROUPS: { id: string; title: string; checks: string[] }[] = [
{
id: 'content',
title: 'Content',
checks: ['albums-shared', 'content-writable', 'alt-text', 'legal', 'contact', 'privacy'],
checks: [
'albums-shared',
'content-pages',
'content-writable',
'alt-text',
'legal',
'contact',
'privacy',
'settings-values',
],
},
];
@@ -65,9 +74,12 @@ const FIXES: Record<string, { label: string; href: string }> = {
passwords: { label: 'Open settings', href: '/admin/settings/security' },
'album-ids': { label: 'Open pages', href: '/admin/pages' },
'albums-shared': { label: 'Open pages', href: '/admin/pages' },
'content-pages': { 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` },
'immich-api': {
label: 'API key permissions',
@@ -320,8 +332,12 @@ export default function DiagnosticsView() {
}
return null; // The photo list toggle is rendered separately.
}
const fix = FIXES[id];
if (!fix) return null;
const known = FIXES[id];
if (!known) return null;
// A finding that knows which settings section holds its fix goes there.
const fix = f.settingsSection
? { ...known, href: `/admin/settings/${f.settingsSection}` }
: known;
return fix.href.startsWith('/') ? (
<Link href={fix.href} className="admin-btn admin-btn-sm">
{fix.label}

View File

@@ -29,6 +29,7 @@ import {
IconX,
} from './Icons';
import { BlockBadge } from './BlockBadge';
import { InlineMarkdownField } from './InlineMarkdownField';
interface EssayBlockEditorProps {
markdown: string;
@@ -291,19 +292,17 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
{/* 2. PARAGRAPH BLOCK
The textarea shows `html` raw, as the journal editor does. It used
to strip the tags for display — `<strong>` to `**`, everything else
to nothing — while `onChange` wrote the stripped text straight back
into `html`. Italics, links, anything the markdown had produced was
destroyed by the act of editing the paragraph, and the two editors
disagreed about the same file. serializeJournalMarkdown() turns the
HTML back into markdown on save and handles `<em>` too, so there
was nothing to convert here in the first place. */}
The field shows the markdown source and writes the rendered HTML
back, as the journal editor does (InlineMarkdownField). An older
version stripped the tags for display and wrote the stripped text
straight back into `html`, destroying italics and links; the one
after that showed the raw HTML. */}
{block.type === 'paragraph' && (
<textarea
<InlineMarkdownField
multiline
rows={3}
value={block.html}
onChange={(e) => handleUpdateBlock(idx, { ...block, html: e.target.value })}
html={block.html}
onChange={(html) => handleUpdateBlock(idx, { ...block, html })}
placeholder="Write text paragraph... (**bold**, *italic* markdown supported)"
/>
)}
@@ -319,10 +318,11 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
paddingLeft: '10px',
}}
>
<textarea
<InlineMarkdownField
multiline
rows={2}
value={block.text}
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
html={block.text}
onChange={(text) => handleUpdateBlock(idx, { ...block, text })}
placeholder="Quote text..."
style={{ fontStyle: 'italic', fontFamily: 'serif', fontSize: '1rem' }}
/>
@@ -411,11 +411,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
</div>
</div>
<input
type="text"
value={block.caption || ''}
onChange={(e) =>
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
<InlineMarkdownField
html={block.caption}
onChange={(caption) =>
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
}
placeholder="Photo caption (optional)"
/>
@@ -573,11 +572,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
</div>
</div>
<input
type="text"
value={block.caption || ''}
onChange={(e) =>
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
<InlineMarkdownField
html={block.caption}
onChange={(caption) =>
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
}
placeholder="Side-by-side caption (optional)"
/>
@@ -693,11 +691,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
<IconPlus size={12} /> Add photo
</button>
<input
type="text"
value={block.caption || ''}
onChange={(e) =>
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
<InlineMarkdownField
html={block.caption}
onChange={(caption) =>
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
}
placeholder="Grid caption (optional)"
/>
@@ -729,16 +726,13 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
})
}
/>
<input
type="text"
value={item.value}
<InlineMarkdownField
html={item.value}
placeholder="Value, e.g. 21 km"
onChange={(e) =>
onChange={(value) =>
handleUpdateBlock(idx, {
...block,
items: block.items.map((it, i) =>
i === fIdx ? { ...it, value: e.target.value } : it,
),
items: block.items.map((it, i) => (i === fIdx ? { ...it, value } : it)),
})
}
/>
@@ -828,13 +822,12 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
<option value="wide">Wide (one per row)</option>
</select>
</div>
<input
type="text"
value={block.caption || ''}
placeholder="Caption for the set (optional)"
onChange={(e) =>
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
<InlineMarkdownField
html={block.caption}
onChange={(caption) =>
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
}
placeholder="Caption for the set (optional)"
/>
<span style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }}>
Expanded into photo blocks when the page renders — the album&apos;s order, a
@@ -846,11 +839,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
{/* 8. MAP BLOCK */}
{block.type === 'map' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<input
type="text"
value={block.caption || ''}
onChange={(e) =>
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
<InlineMarkdownField
html={block.caption}
onChange={(caption) =>
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
}
placeholder="Caption, e.g. Busan → Seoul (optional)"
/>

View File

@@ -0,0 +1,63 @@
'use client';
import { useState, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
import { inlineHtmlToMarkdown, renderInlineMarkdown } from '@/lib/journal';
type SharedProps = {
/**
* The block field as the parser stores it: HTML from renderInlineMarkdown
* (a paragraph's `html`, a quote's `text`, a caption, a fact value).
*/
html: string | undefined;
/** Receives the field as HTML again, rendered from what was typed. */
onChange: (html: string) => void;
};
type InputProps = SharedProps & {
multiline?: false;
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'>;
type TextareaProps = SharedProps & {
multiline: true;
} & Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'>;
/**
* A text field for an inline-markdown block field (A-5).
*
* Blocks hold rendered HTML, since that is what the public page and the live
* preview show. The editors used to put that HTML straight into the field: a
* reloaded entry showed `<strong>` and `<a href="…">` in the textarea, text
* typed into a tag was lost on save, and typed `**bold**` stayed literal in
* the preview until a save re-parsed the file.
*
* The field shows the markdown source instead and hands every edit back
* rendered, so the preview is right while typing and the serializer writes
* the same markdown for an untouched block. What was typed is kept as typed:
* deriving the field from the HTML on every keystroke would rewrite `__x__`
* to `**x**`, or drop a half-typed link, under the cursor. The field re-reads
* the HTML only when it changes from outside (markdown mode, a reorder, a
* restore).
*/
export function InlineMarkdownField(props: InputProps | TextareaProps) {
const html = props.html ?? '';
const [text, setText] = useState(() => inlineHtmlToMarkdown(html));
const [shownHtml, setShownHtml] = useState(html);
if (html !== shownHtml) {
setShownHtml(html);
setText(inlineHtmlToMarkdown(html));
}
const update = (next: string) => {
const nextHtml = renderInlineMarkdown(next);
setText(next);
setShownHtml(nextHtml);
props.onChange(nextHtml);
};
if (props.multiline) {
const { html: _html, onChange: _onChange, multiline: _multiline, ...rest } = props;
return <textarea {...rest} value={text} onChange={(e) => update(e.target.value)} />;
}
const { html: _html, onChange: _onChange, multiline: _multiline, ...rest } = props;
return <input type="text" {...rest} value={text} onChange={(e) => update(e.target.value)} />;
}

View File

@@ -24,7 +24,16 @@ import SaveBar, { type SaveStatus } from './SaveBar';
import AlbumDrawer from './page-builder/AlbumDrawer';
import { SortableAlbumCard } from './page-builder/AlbumCard';
import SubpageDrawer from './page-builder/SubpageDrawer';
import { SortableHeroTile, SortableSubpageRow } from './page-builder/SortableTiles';
import {
OffMenuDropZone,
SortableHeroTile,
SortablePageRow,
SortableSubpageRow,
} from './page-builder/SortableTiles';
import PagePanel from './page-builder/PagePanel';
import { isValidSlug } from '@/lib/journal';
import NewPageDialog from './page-builder/NewPageDialog';
import type { PageSummary, SlugTakenBy } from '@/lib/pages';
import { findAlbumAddress } from './page-builder/findAlbumAddress';
import { parseGalleryYaml, serializeGallery, type GalleryState } from './page-builder/galleryYaml';
import * as ops from './page-builder/galleryOps';
@@ -41,11 +50,22 @@ import {
import { useScrollLock } from './useScrollLock';
import { useUnsavedGuard } from './useUnsavedGuard';
import { useDraft } from './useDraft';
import { useLatest } from './useLatest';
import { useVersionedSave } from './useVersionedSave';
import type { GalleryVersionChange } from '@/lib/admin/pageRefs';
import DraftNotice from './DraftNotice';
import { reportIfSessionExpired } from './sessionExpiry';
import { IconCamera, IconHome, IconPlus, IconSearch } from './Icons';
import { useNotify } from './Notifications';
/** Drop target id of the "Not in menu" group. */
const OFF_MENU_ID = 'offmenu-zone';
/** Sortable id of a menu row. Subpages keep their old `subpage-<index>` id. */
function menuRowId(item: ops.MenuItem): string {
return item.kind === 'subpage' ? `subpage-${item.index}` : `page-${item.slug}`;
}
export default function PageBuilder() {
const [gallery, setGallery] = useState<GalleryState>({ hero: [], albums: [], subpages: [] });
const notify = useNotify();
@@ -79,6 +99,21 @@ export default function PageBuilder() {
/** Album a `?album=` link pointed at, marked in its subpage sheet for a moment. */
const [linkedAlbumId, setLinkedAlbumId] = useState<string | null>(null);
// ── Content pages (#722) ──
/** Every page file; the menu itself lives in `gallery.pageRefs`. */
const [pages, setPages] = useState<PageSummary[]>([]);
const [takenSlugs, setTakenSlugs] = useState<SlugTakenBy>({
subpages: [],
albums: [],
journal: [],
});
/** The page shown in the panel; exclusive with `expandedSubpage`. */
const [selectedPage, setSelectedPage] = useState<string | null>(null);
const [showNewPage, setShowNewPage] = useState(false);
const [creatingPage, setCreatingPage] = useState(false);
/** The edit panel beside (or, on narrow screens, below) the structure list. */
const panelRef = useRef<HTMLDivElement>(null);
// Keep the builder still behind either drawer. One combined lock rather than
// one per drawer: the album drawer opens from inside the subpage drawer, and
// a single condition avoids two locks racing over the same inline style.
@@ -121,7 +156,12 @@ export default function PageBuilder() {
// Unsaved edits survive leaving the builder — a tab link, back, Reload,
// Logout (#592). `serverState` is what a discard goes back to.
const draft = useDraft<GalleryState>('page-builder', gallery, dirty);
/** The tree as last rendered: tells a finished save whether editing went on meanwhile. */
const latestGallery = useLatest(gallery);
const serverState = useRef<GalleryState | null>(null);
/** gallery.yaml as loaded, sent back on save so a change elsewhere is caught (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
// A subpage opened from a `?album=` link can hold dozens of albums: bring the
// linked one into view, and let the mark fade after a moment. The mark itself
@@ -147,9 +187,10 @@ export default function PageBuilder() {
setLoading(true);
setLoadError(null);
try {
const [galleryRes, albumsRes] = await Promise.all([
const [galleryRes, albumsRes, pagesRes] = await Promise.all([
fetch('/api/admin/gallery'),
fetch('/api/admin/albums'),
fetch('/api/admin/pages'),
]);
if (!galleryRes.ok) {
@@ -160,7 +201,8 @@ export default function PageBuilder() {
);
}
const { gallery: raw } = await galleryRes.json();
const { gallery: raw, version } = await galleryRes.json();
versionRef.current = typeof version === 'string' ? version : null;
const parsed = parseGalleryYaml(raw);
serverState.current = parsed;
const restored = draft.load(JSON.stringify(parsed));
@@ -176,6 +218,15 @@ export default function PageBuilder() {
const { albums } = await albumsRes.json();
setImmichAlbums(albums);
}
// Pages are survivable the same way: without the list the menu still
// shows their references, and saving keeps them.
if (pagesRes.ok) {
const data = (await pagesRes.json()) as { pages: PageSummary[]; taken: SlugTakenBy };
setPages(data.pages);
setTakenSlugs(data.taken);
}
openPageFromLink();
} catch (err) {
console.error('Failed to load admin data:', err);
setLoadError(err instanceof Error ? err.message : 'The page structure could not be loaded.');
@@ -213,6 +264,21 @@ export default function PageBuilder() {
}
}
/** `?page=<slug>` selects a page — the page editor's back link uses it. One-shot. */
function openPageFromLink() {
const params = new URLSearchParams(window.location.search);
const slug = params.get('page');
if (slug === null) return;
params.delete('page');
const query = params.toString();
window.history.replaceState(null, '', window.location.pathname + (query ? `?${query}` : ''));
// The slug comes from the address bar and becomes the page's live link, so
// only a real page slug may select a page — `//host` would leave the site.
if (!isValidSlug(slug)) return;
setExpandedSubpage(null);
setSelectedPage(slug);
}
function discardDraft() {
draft.discard();
if (serverState.current) setGallery(serverState.current);
@@ -240,17 +306,24 @@ export default function PageBuilder() {
setSaving(true);
setSaveStatus(null);
const yamlData = serializeGallery(gallery);
const sent = gallery;
const yamlData = serializeGallery(sent);
try {
const res = await fetch('/api/admin/gallery', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gallery: yamlData }),
});
const result = await versionedSave(
'/api/admin/gallery',
{ gallery: yamlData },
versionRef,
'The page structure (gallery.yaml)',
);
if (res.ok) {
const data = await res.json();
if (result.kind === 'reload') {
draft.discard();
await loadData();
} else if (result.kind === 'keep') {
setSaveStatus({ kind: 'error', message: 'Not saved — gallery.yaml changed elsewhere.' });
} else if (result.kind === 'saved') {
const data = result.data as { gallery?: unknown; message?: string };
// Fingerprint what the next load will see, the way it will see it: the
// file as written, read back through the same parser. The editor's own
// state can differ in shape (an `undefined` here, a default there) and
@@ -262,15 +335,22 @@ export default function PageBuilder() {
const written = (data.gallery ?? yamlData) as Record<string, unknown>;
const asLoaded = parseGalleryYaml(JSON.parse(JSON.stringify(written)));
serverState.current = asLoaded;
draft.saved(JSON.stringify(asLoaded));
// Show the hashes, so a new password reads "Protected" right away.
if (JSON.stringify(written) !== JSON.stringify(yamlData)) setGallery(asLoaded);
setDirty(false);
// 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;
draft.saved(JSON.stringify(asLoaded), editedMeanwhile);
if (!editedMeanwhile) {
// Show the hashes, so a new password reads "Protected" right away.
if (JSON.stringify(written) !== JSON.stringify(yamlData)) setGallery(asLoaded);
setDirty(false);
}
setSaveStatus({ kind: 'success', message: data.message || 'Saved successfully!' });
setTimeout(() => setSaveStatus(null), 5000);
} else if (!reportIfSessionExpired(res)) {
const err = await res.json();
setSaveStatus({ kind: 'error', message: `Error: ${err.error}` });
} else if (!reportIfSessionExpired(result.res)) {
setSaveStatus({
kind: 'error',
message: `Error: ${result.data?.error ?? `HTTP ${result.res.status}`}`,
});
}
} catch {
setSaveStatus({ kind: 'error', message: 'Error: Failed to save' });
@@ -286,8 +366,8 @@ export default function PageBuilder() {
}
// ── Pickers ──────────────────────────────────────────────────
function handleHeroSelect(assetId: string) {
edit((g) => ops.addHero(g, assetId));
function handleHeroSelectMany(assetIds: string[]) {
edit((g) => ops.addHeroes(g, assetIds));
setHeroPickerTarget(null);
}
@@ -322,17 +402,158 @@ export default function PageBuilder() {
}
}
function handleSubpageDragEnd(event: DragEndEvent) {
/**
* The menu list holds subpages and pages in one order (#722). A page can
* also be dragged into or out of "Not in menu", which toggles its "Show in
* menu" switch.
*/
function handleMenuDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const activeId = String(active.id);
const overId = String(over.id);
const items = ops.menuItems(gallery);
const rowIndex = (id: string) => items.findIndex((item) => menuRowId(item) === id);
const overOffMenu = overId === OFF_MENU_ID || overId.startsWith('off-');
const oldIndex = gallery.subpages.findIndex((_, i) => `subpage-${i}` === active.id);
const newIndex = gallery.subpages.findIndex((_, i) => `subpage-${i}` === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
edit((g) => ops.moveSubpage(g, oldIndex, newIndex));
setExpandedSubpage(ops.followMovedIndex(expandedSubpage, oldIndex, newIndex));
if (activeId.startsWith('off-')) {
if (overOffMenu) return;
const at = rowIndex(overId);
if (at === -1) return;
const slug = activeId.slice('off-'.length);
edit((g) => ops.setPageInMenu(g, slug, true, at));
return;
}
const from = rowIndex(activeId);
if (from === -1) return;
if (overOffMenu) {
const item = items[from];
if (item.kind === 'page') edit((g) => ops.setPageInMenu(g, item.slug, false));
return;
}
const to = rowIndex(overId);
if (to === -1) return;
setExpandedSubpage(ops.followMenuMove(gallery, expandedSubpage, from, to));
edit((g) => ops.moveMenuItem(g, from, to));
}
// ── Content pages ────────────────────────────────────────────
function selectPage(slug: string) {
setExpandedSubpage(null);
setLinkedAlbumId(null);
setSelectedPage(slug);
revealPanel();
}
function selectSubpage(index: number) {
setSelectedPage(null);
setExpandedSubpage(index);
revealPanel();
}
function selectOverview(which: 'hero' | 'albums') {
setExpandedSubpage(null);
setSelectedPage(null);
setOverview(which);
revealPanel();
}
/**
* Below 1100px the panel stacks under the structure list, so on a phone a
* tapped row changed something a screen further down and looked like it
* did nothing. Scroll the panel up once it has rendered.
*/
function revealPanel() {
if (!window.matchMedia?.('(max-width: 1100px)').matches) return;
requestAnimationFrame(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
panelRef.current?.scrollIntoView({ block: 'start', behavior: reduce ? 'auto' : 'smooth' });
});
}
const togglePageMenu = (slug: string) =>
edit((g) => ops.setPageInMenu(g, slug, !ops.isPageInMenu(g, slug)));
/**
* gallery.yaml was changed on the server by a page rename or delete. The
* same change goes into the builder's state and into what it considers
* saved, so neither a pending edit nor the draft check trips over it.
*/
function applyServerMenuChange(
op: (g: GalleryState) => GalleryState,
change?: GalleryVersionChange | null,
) {
setGallery(op);
// Follow the rewrite only when it was the sole change since this editor
// loaded; otherwise the old version stays and the next save asks (#601).
if (change && versionRef.current === change.from) versionRef.current = change.to;
if (serverState.current) {
serverState.current = op(serverState.current);
draft.saved(JSON.stringify(serverState.current));
}
}
async function createPage(input: { title: string; slug: string; showInMenu: boolean }) {
setCreatingPage(true);
try {
const res = await fetch('/api/admin/pages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: input.title, slug: input.slug }),
});
const data = await res.json().catch(() => null);
if (!res.ok) {
if (!reportIfSessionExpired(res))
notify('error', data?.error || 'Failed to create the page');
return;
}
const slug: string = data.page.slug;
setPages((prev) => [
...prev.filter((p) => p.slug !== slug),
{ slug, frontmatter: { title: input.title, draft: true } },
]);
if (input.showInMenu) edit((g) => ops.setPageInMenu(g, slug, true));
setShowNewPage(false);
selectPage(slug);
notify(
'success',
input.showInMenu
? 'Page created as a draft. Save Changes to put it in the menu.'
: 'Page created as a draft.',
);
} catch {
notify('error', 'Could not create the page. Check the connection and try again.');
} finally {
setCreatingPage(false);
}
}
function handlePageSaved(
oldSlug: string,
page: PageSummary,
menuRenamed: boolean,
galleryVersion?: GalleryVersionChange | null,
) {
setPages((prev) => [...prev.filter((p) => p.slug !== oldSlug), page]);
if (page.slug !== oldSlug) {
const rename = (g: GalleryState) => ops.renamePageRef(g, oldSlug, page.slug);
if (menuRenamed) applyServerMenuChange(rename, galleryVersion);
else setGallery(rename);
setSelectedPage(page.slug);
}
}
function handlePageDeleted(
slug: string,
removedFromMenu: boolean,
galleryVersion?: GalleryVersionChange | null,
) {
setPages((prev) => prev.filter((p) => p.slug !== slug));
const remove = (g: GalleryState) => ops.setPageInMenu(g, slug, false);
if (removedFromMenu) applyServerMenuChange(remove, galleryVersion);
else setGallery(remove);
setSelectedPage(null);
}
function handleSubpageAlbumDragEnd(spIndex: number) {
@@ -510,6 +731,16 @@ export default function PageBuilder() {
);
});
// The menu: subpages and pages in saved order (#722). Search filters it.
const pageBySlug = new Map(pages.map((p) => [p.slug, p]));
const pageTitle = (slug: string) => pageBySlug.get(slug)?.frontmatter.title || slug;
const query = searchQuery.toLowerCase();
const pageMatches = (slug: string) =>
!searchQuery || slug.includes(query) || pageTitle(slug).toLowerCase().includes(query);
const offMenuPages = pages
.filter((p) => !ops.isPageInMenu(gallery, p.slug) && pageMatches(p.slug))
.sort((a, b) => pageTitle(a.slug).localeCompare(pageTitle(b.slug)));
// Filter subpages
const filteredSubpages = gallery.subpages
.map((sp, index) => ({ sp, index }))
@@ -537,6 +768,12 @@ export default function PageBuilder() {
return hasMatchingAlbum;
});
const shownSubpages = new Set(filteredSubpages.map(({ index }) => index));
const menuRows = ops
.menuItems(gallery)
.filter((item) =>
item.kind === 'subpage' ? shownSubpages.has(item.index) : pageMatches(item.slug),
);
return (
<div className="page-builder">
@@ -593,10 +830,7 @@ export default function PageBuilder() {
<button
type="button"
className={`pb-row-main pb-row-solo ${expandedSubpage === null && overview === 'hero' ? 'active' : ''}`}
onClick={() => {
setExpandedSubpage(null);
setOverview('hero');
}}
onClick={() => selectOverview('hero')}
>
<span className="pb-row-thumb">
{gallery.hero[0] ? (
@@ -610,49 +844,102 @@ export default function PageBuilder() {
</button>
</div>
<div className="pb-group">
<div className="pb-group-head">
<span>Subpages</span>
<span>
{gallery.subpages.filter((sp) => sp.enabled !== false).length} of{' '}
{gallery.subpages.length} live
</span>
</div>
{gallery.subpages.length > 0 && filteredSubpages.length === 0 && (
<p className="empty-hint">No matching subpages.</p>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleSubpageDragEnd}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleMenuDragEnd}
>
<div className="pb-group">
<div className="pb-group-head">
<span>Menu</span>
<span>
{gallery.subpages.filter((sp) => sp.enabled !== false).length} of{' '}
{gallery.subpages.length} subpages live
</span>
</div>
{(gallery.subpages.length > 0 || (gallery.pageRefs ?? []).length > 0) &&
menuRows.length === 0 && <p className="empty-hint">No matching entries.</p>}
<SortableContext
items={filteredSubpages.map(({ index }) => `subpage-${index}`)}
items={menuRows.map(menuRowId)}
strategy={verticalListSortingStrategy}
>
{filteredSubpages.map(({ sp, index }) => (
<SortableSubpageRow
key={`subpage-${index}`}
sp={sp}
spIndex={index}
isActive={expandedSubpage === index}
onClick={() => setExpandedSubpage(index)}
getFirstThumb={getFirstSubpageThumb}
/>
))}
{menuRows.map((item) => {
if (item.kind === 'subpage') {
const sp = gallery.subpages[item.index];
return (
<SortableSubpageRow
key={menuRowId(item)}
sp={sp}
spIndex={item.index}
isActive={expandedSubpage === item.index}
onClick={() => selectSubpage(item.index)}
getFirstThumb={getFirstSubpageThumb}
/>
);
}
const page = pageBySlug.get(item.slug);
return (
<SortablePageRow
key={menuRowId(item)}
id={menuRowId(item)}
title={pageTitle(item.slug)}
isActive={selectedPage === item.slug}
draft={page?.frontmatter.draft}
hasPassword={!!page?.frontmatter.password}
// Only once the list has loaded: an empty list is not proof.
missing={pages.length > 0 && !page}
onClick={() => selectPage(item.slug)}
/>
);
})}
</SortableContext>
</DndContext>
<button
type="button"
className="pb-add"
onClick={() => {
addSubpage();
setExpandedSubpage(gallery.subpages.length);
}}
>
<IconPlus size={13} /> New subpage
</button>
</div>
<div className="pb-add-row">
<button
type="button"
className="pb-add"
onClick={() => {
addSubpage();
selectSubpage(gallery.subpages.length);
}}
>
<IconPlus size={13} /> New subpage
</button>
<button type="button" className="pb-add" onClick={() => setShowNewPage(true)}>
<IconPlus size={13} /> New page
</button>
</div>
</div>
<div className="pb-group">
<div className="pb-group-head">
<span>Not in menu</span>
<span>{offMenuPages.length}</span>
</div>
<OffMenuDropZone id={OFF_MENU_ID}>
<SortableContext
items={offMenuPages.map((p) => `off-${p.slug}`)}
strategy={verticalListSortingStrategy}
>
{offMenuPages.map((p) => (
<SortablePageRow
key={`off-${p.slug}`}
id={`off-${p.slug}`}
title={pageTitle(p.slug)}
isActive={selectedPage === p.slug}
draft={p.frontmatter.draft}
hasPassword={!!p.frontmatter.password}
onClick={() => selectPage(p.slug)}
/>
))}
</SortableContext>
{offMenuPages.length === 0 && (
<p className="empty-hint">
Pages reachable only by their link. Drag a page here to take it out of the menu.
</p>
)}
</OffMenuDropZone>
</div>
</DndContext>
<div className="pb-group">
<div className="pb-group-head">
@@ -661,10 +948,7 @@ export default function PageBuilder() {
<button
type="button"
className={`pb-row-main pb-row-solo ${expandedSubpage === null && overview === 'albums' ? 'active' : ''}`}
onClick={() => {
setExpandedSubpage(null);
setOverview('albums');
}}
onClick={() => selectOverview('albums')}
>
<span className="pb-row-thumb">
<IconCamera size={14} />
@@ -676,8 +960,19 @@ export default function PageBuilder() {
</aside>
{/* The selected entry, edited in place instead of in an overlay. */}
<div className="pb-panel">
{expandedSubpage !== null && gallery.subpages[expandedSubpage] ? (
<div className="pb-panel" ref={panelRef}>
{selectedPage !== null ? (
<PagePanel
key={selectedPage}
slug={selectedPage}
inMenu={ops.isPageInMenu(gallery, selectedPage)}
onToggleMenu={() => togglePageMenu(selectedPage)}
taken={takenSlugs}
otherPageSlugs={pages.map((p) => p.slug).filter((s) => s !== selectedPage)}
onSaved={handlePageSaved}
onDeleted={handlePageDeleted}
/>
) : expandedSubpage !== null && gallery.subpages[expandedSubpage] ? (
<>
<SubpageDrawer
sp={gallery.subpages[expandedSubpage]}
@@ -782,9 +1077,9 @@ export default function PageBuilder() {
className="admin-btn admin-btn-sm"
onClick={() =>
setHeroPickerTarget({
onSelect: handleHeroSelect,
onSelectMany: handleHeroSelectMany,
currentAssetIds: gallery.hero,
title: 'Pick Hero Image for Homepage',
title: 'Pick Hero Images for Homepage',
})
}
>
@@ -824,6 +1119,16 @@ export default function PageBuilder() {
</div>
</div>
{showNewPage && (
<NewPageDialog
taken={takenSlugs}
existingPageSlugs={pages.map((p) => p.slug)}
creating={creatingPage}
onCreate={createPage}
onClose={() => setShowNewPage(false)}
/>
)}
{/* Album Picker Modal */}
{pickerTarget && (
<AlbumPicker
@@ -855,6 +1160,7 @@ export default function PageBuilder() {
<AssetPicker
albumId={heroPickerTarget.albumId}
onSelect={heroPickerTarget.onSelect}
onSelectMany={heroPickerTarget.onSelectMany}
onClose={() => setHeroPickerTarget(null)}
currentAssetIds={heroPickerTarget.currentAssetIds}
title={heroPickerTarget.title}

View File

@@ -11,10 +11,15 @@
* key in settings.yaml.
*/
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Icons from './Icons';
import { reportIfSessionExpired } from './sessionExpiry';
import { useUnsavedGuard } from './useUnsavedGuard';
import { useContentRestored } from './contentRestored';
import { useVersionedSave } from './useVersionedSave';
import { useLatest } from './useLatest';
import { useDraft } from './useDraft';
import DraftNotice from './DraftNotice';
import type { ProcessingFact } from '@/lib/privacy';
interface PrivacyData {
@@ -22,6 +27,7 @@ interface PrivacyData {
enabled: boolean;
facts: ProcessingFact[];
starter: string;
version?: string;
}
type Status = { kind: 'success' | 'error'; message: string } | null;
@@ -35,44 +41,80 @@ export default function PrivacyEditor() {
const [status, setStatus] = useState<Status>(null);
useUnsavedGuard(dirty);
// The editor lives in the Legal section and unmounts when another section
// opens; without a draft an unsaved policy was simply gone (#554, #592).
const draft = useDraft<string>('privacy', body, dirty);
const loadDraft = draft.load;
/** privacy.md as loaded, sent back on save so a change elsewhere is caught (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
/** The text as last rendered: tells a finished save whether typing went on meanwhile. */
const latestBody = useLatest(body);
const dirtyRef = useRef(dirty);
useEffect(() => {
dirtyRef.current = dirty;
});
const load = useCallback(async (): Promise<void> => {
try {
const res = await fetch('/api/admin/privacy');
if (!res.ok) {
reportIfSessionExpired(res);
throw new Error(`HTTP ${res.status}`);
}
const d = (await res.json()) as PrivacyData;
versionRef.current = d.version ?? null;
setData(d);
const restored = loadDraft(d.body);
setBody(restored ?? d.body);
setDirty(restored !== null);
} catch (err) {
setLoadError(err instanceof Error ? err.message : 'Could not load.');
}
}, [loadDraft]);
useEffect(() => {
let live = true;
fetch('/api/admin/privacy')
.then(async (res) => {
if (!res.ok) {
reportIfSessionExpired(res);
throw new Error(`HTTP ${res.status}`);
}
return (await res.json()) as PrivacyData;
})
.then((d) => {
if (!live) return;
setData(d);
setBody(d.body);
})
.catch((err: Error) => live && setLoadError(err.message));
return () => {
live = false;
};
}, []);
void load();
}, [load]);
// A restored privacy.md replaces what this editor shows — unless there are
// unsaved edits, which stay; their save then meets the conflict prompt.
useContentRestored(({ target }) => {
if (target === 'privacy' && !dirtyRef.current) void load();
});
async function save() {
setSaving(true);
setStatus(null);
const sent = body;
try {
const res = await fetch('/api/admin/privacy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
if (!res.ok) {
if (!reportIfSessionExpired(res)) {
setStatus({ kind: 'error', message: `Could not save (HTTP ${res.status}).` });
const result = await versionedSave(
'/api/admin/privacy',
{ body: sent },
versionRef,
'The privacy policy',
);
if (result.kind === 'reload') {
draft.discard();
await load();
return;
}
if (result.kind === 'keep') {
setStatus({ kind: 'error', message: 'Not saved — the policy changed elsewhere.' });
return;
}
if (result.kind === 'failed') {
if (!reportIfSessionExpired(result.res)) {
setStatus({ kind: 'error', message: `Could not save (HTTP ${result.res.status}).` });
}
return;
}
setDirty(false);
// Typed while the request was out: that part is not saved yet.
const editedMeanwhile = latestBody.current !== sent;
setData((d) => (d ? { ...d, body: sent } : d));
draft.saved(sent, editedMeanwhile);
if (!editedMeanwhile) setDirty(false);
setStatus({ kind: 'success', message: 'Privacy policy saved.' });
} catch {
setStatus({ kind: 'error', message: 'Could not reach the server.' });
@@ -99,6 +141,24 @@ export default function PrivacyEditor() {
return (
<div className="privacy-editor">
<DraftNotice
status={draft.status}
subject="privacy policy"
onDiscard={() => {
draft.discard();
setBody(data.body);
setDirty(false);
setStatus(null);
}}
onRestore={() => {
const value = draft.takeConflicting();
if (value === null) return;
setBody(value);
setDirty(true);
}}
onDismiss={draft.dismiss}
/>
<div className="privacy-editor__facts">
<h4>What this site processes</h4>
<p className="admin-field-hint">

View File

@@ -0,0 +1,468 @@
'use client';
/**
* Admin → Proofing: client links, their selections and the export.
*
* One card per link. Creating a link picks a client name and an album — any
* Immich album, published or not — plus optional expiry and download rights.
* A card expands into the selected photos and the export buttons.
*/
import { useCallback, useEffect, useState } from 'react';
import PageHeader from './PageHeader';
import * as Icons from './Icons';
import { reportIfSessionExpired } from './sessionExpiry';
import type { AdminProofPick, AdminProofSession } from '@/lib/admin/proofing-view';
interface AlbumOption {
id: string;
albumName: string;
assetCount: number;
}
type Download = AdminProofSession['download'];
const STATE_LABEL: Record<AdminProofSession['state'], string> = {
open: 'Open',
submitted: 'Submitted',
expired: 'Expired',
};
const DOWNLOAD_LABEL: Record<Download, string> = {
none: 'No downloads',
selection: 'Selection as ZIP',
album: 'Whole album as ZIP',
};
function formatDate(iso: string | null): string {
if (!iso) return '—';
const date = iso.length === 10 ? new Date(`${iso}T12:00:00`) : new Date(iso);
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
async function api<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (reportIfSessionExpired(res)) throw new Error('Session expired');
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error || `HTTP ${res.status}`);
return data as T;
}
function CreateForm({ onCreated }: { onCreated: () => void }) {
const [albums, setAlbums] = useState<AlbumOption[] | null>(null);
const [clientName, setClientName] = useState('');
const [albumId, setAlbumId] = useState('');
const [expiresOn, setExpiresOn] = useState('');
const [download, setDownload] = useState<Download>('none');
const [limit, setLimit] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api<{ albums: AlbumOption[] }>('/api/admin/albums')
.then((data) =>
setAlbums([...data.albums].sort((a, b) => a.albumName.localeCompare(b.albumName))),
)
.catch((err: Error) => setError(err.message));
}, []);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
await api('/api/admin/proofing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientName,
albumId,
expiresOn: expiresOn || null,
download,
downloadLimit: download !== 'none' && limit ? Number(limit) : null,
}),
});
setClientName('');
setAlbumId('');
setExpiresOn('');
setDownload('none');
setLimit('');
onCreated();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<form className="analytics-panel proofing-create" onSubmit={submit}>
<div className="analytics-section-title">
<h3>
<Icons.IconPlus size={16} /> New client link
</h3>
</div>
<div className="proofing-create-grid">
<div className="admin-field">
<label htmlFor="proof-client">Client</label>
<input
id="proof-client"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="Anna & Ben"
maxLength={120}
required
/>
</div>
<div className="admin-field">
<label htmlFor="proof-album">Album</label>
<select
id="proof-album"
value={albumId}
onChange={(e) => setAlbumId(e.target.value)}
required
disabled={!albums}
>
<option value="">{albums ? 'Choose an Immich album…' : 'Loading albums…'}</option>
{albums?.map((album) => (
<option key={album.id} value={album.id}>
{album.albumName} ({album.assetCount})
</option>
))}
</select>
</div>
<div className="admin-field">
<label htmlFor="proof-expires">Valid until (optional)</label>
<input
id="proof-expires"
type="date"
value={expiresOn}
onChange={(e) => setExpiresOn(e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor="proof-download">Downloads</label>
<select
id="proof-download"
value={download}
onChange={(e) => setDownload(e.target.value as Download)}
>
{(Object.keys(DOWNLOAD_LABEL) as Download[]).map((key) => (
<option key={key} value={key}>
{DOWNLOAD_LABEL[key]}
</option>
))}
</select>
</div>
{download !== 'none' && (
<div className="admin-field">
<label htmlFor="proof-limit">Download limit (optional)</label>
<input
id="proof-limit"
type="number"
min={1}
max={1000}
value={limit}
onChange={(e) => setLimit(e.target.value)}
placeholder="Unlimited"
/>
</div>
)}
</div>
{error && <p className="admin-error">{error}</p>}
<div>
<button
type="submit"
className="admin-btn admin-btn-primary"
disabled={busy || !clientName.trim() || !albumId}
>
<Icons.IconLink size={14} /> {busy ? 'Creating…' : 'Create link'}
</button>
</div>
</form>
);
}
function SessionCard({
session,
expanded,
onToggle,
onChanged,
}: {
session: AdminProofSession;
expanded: boolean;
onToggle: () => void;
onChanged: () => void;
}) {
const [picks, setPicks] = useState<AdminProofPick[] | null>(null);
const [copied, setCopied] = useState<'link' | 'lightroom' | null>(null);
const [error, setError] = useState<string | null>(null);
const linkUrl = typeof window !== 'undefined' ? `${window.location.origin}${session.link}` : '';
useEffect(() => {
if (!expanded) return;
api<{ picks: AdminProofPick[] }>(`/api/admin/proofing/${session.id}`)
.then((data) => setPicks(data.picks))
.catch((err: Error) => setError(err.message));
}, [expanded, session.id, session.selected, session.submittedAt]);
const copy = (kind: 'link' | 'lightroom', text: string) => {
if (!navigator.clipboard) {
window.prompt('Copy:', text);
return;
}
navigator.clipboard.writeText(text).then(
() => {
setCopied(kind);
setTimeout(() => setCopied(null), 2000);
},
() => window.prompt('Copy:', text),
);
};
const patch = async (body: Record<string, unknown>) => {
setError(null);
try {
await api(`/api/admin/proofing/${session.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
onChanged();
} catch (err) {
setError((err as Error).message);
}
};
const remove = async () => {
if (!window.confirm(`Delete the link for ${session.clientName}? It stops working at once.`)) {
return;
}
try {
await api(`/api/admin/proofing/${session.id}`, { method: 'DELETE' });
onChanged();
} catch (err) {
setError((err as Error).message);
}
};
const copyLightroom = async () => {
try {
const res = await fetch(`/api/admin/proofing/${session.id}/export?format=lightroom`);
if (reportIfSessionExpired(res)) return;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
copy('lightroom', await res.text());
} catch (err) {
setError((err as Error).message);
}
};
const downloadsText =
session.download === 'none'
? DOWNLOAD_LABEL.none
: `${DOWNLOAD_LABEL[session.download]} · ${session.downloadsUsed}${
session.downloadLimit !== null ? ` / ${session.downloadLimit}` : ''
} used`;
return (
<div className={`analytics-panel proofing-card proofing-card--${session.state}`}>
<div className="proofing-card-head">
<div>
<h3 className="proofing-card-title">
{session.clientName}
<span className={`proofing-badge proofing-badge--${session.state}`}>
{STATE_LABEL[session.state]}
</span>
</h3>
<p className="analytics-subtitle">
{session.albumName ?? 'Album not found in Immich'} · {session.selected}
{session.albumAssetCount !== null ? ` of ${session.albumAssetCount}` : ''} selected ·
created {formatDate(session.createdAt)}
{session.expiresOn ? ` · valid until ${formatDate(session.expiresOn)}` : ''}
</p>
<p className="analytics-subtitle">
{downloadsText}
{session.submittedAt ? ` · submitted ${formatDate(session.submittedAt)}` : ''}
</p>
</div>
<div className="proofing-card-actions">
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => copy('link', linkUrl)}
>
{copied === 'link' ? <Icons.IconCheck size={14} /> : <Icons.IconCopy size={14} />}{' '}
{copied === 'link' ? 'Copied' : 'Copy link'}
</button>
<a
className="admin-btn admin-btn-ghost admin-btn-sm"
href={session.link}
target="_blank"
rel="noreferrer"
>
<Icons.IconEye size={14} /> Open
</a>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={onToggle}
aria-expanded={expanded}
>
{expanded ? <Icons.IconChevronUp size={14} /> : <Icons.IconChevronDown size={14} />}{' '}
Selection
</button>
</div>
</div>
{error && <p className="admin-error">{error}</p>}
{expanded && (
<div className="proofing-card-body">
<div className="proofing-card-actions">
<button
type="button"
className="admin-btn admin-btn-primary admin-btn-sm"
onClick={copyLightroom}
disabled={session.selected === 0}
title="File names without extension, comma-separated — paste into Lightroom's or Capture One's filename filter to find the RAW files too"
>
{copied === 'lightroom' ? (
<Icons.IconCheck size={14} />
) : (
<Icons.IconCopy size={14} />
)}{' '}
{copied === 'lightroom' ? 'Copied' : 'Copy file names (Lightroom)'}
</button>
<a
className="admin-btn admin-btn-ghost admin-btn-sm"
href={`/api/admin/proofing/${session.id}/export?format=csv`}
>
<Icons.IconFileText size={14} /> CSV
</a>
<a
className="admin-btn admin-btn-ghost admin-btn-sm"
href={`/api/admin/proofing/${session.id}/export?format=txt`}
>
<Icons.IconFileText size={14} /> TXT
</a>
{session.submittedAt && (
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => patch({ reopen: true })}
>
<Icons.IconRefresh size={14} /> Reopen for changes
</button>
)}
{session.download !== 'none' && session.downloadsUsed > 0 && (
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => patch({ resetDownloads: true })}
>
<Icons.IconRefresh size={14} /> Reset downloads
</button>
)}
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm admin-btn-icon-danger"
onClick={remove}
>
<Icons.IconTrash size={14} /> Delete link
</button>
</div>
{picks === null ? (
<div className="admin-spinner" />
) : picks.length === 0 ? (
<p className="analytics-empty">No photos selected yet.</p>
) : (
<ul className="proofing-picks">
{picks.map((pick) => (
<li key={pick.assetId} className="proofing-pick">
{/* eslint-disable-next-line @next/next/no-img-element -- admin thumbnails, served by our own proxy */}
<img src={pick.thumbUrl} alt="" loading="lazy" />
<span className="proofing-pick-name" title={pick.fileName}>
#{pick.position} · {pick.fileName}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
export default function ProofingView() {
const [sessions, setSessions] = useState<AdminProofSession[] | null>(null);
const [webhookConfigured, setWebhookConfigured] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const data = await api<{ sessions: AdminProofSession[]; webhookConfigured: boolean }>(
'/api/admin/proofing',
);
setSessions(data.sessions);
setWebhookConfigured(data.webhookConfigured);
setError(null);
} catch (err) {
setError((err as Error).message);
}
}, []);
useEffect(() => {
// The webhook's admin link lands here with ?session=<id>: open that card.
const wanted = new URLSearchParams(window.location.search).get('session');
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time read of the URL on mount
if (wanted) setExpanded(wanted);
void load();
}, [load]);
return (
<div className="analytics-view proofing-view">
<PageHeader
kicker="Visitors"
title="Client proofing"
description="A private link per client. Picks are saved as they go; you see them here once they submit."
actions={
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={() => void load()}>
<Icons.IconRefresh size={14} /> Refresh
</button>
}
/>
{!webhookConfigured && (
<p className="analytics-subtitle">
Tip: set <code>PROOFING_WEBHOOK_URL</code> to be notified (Discord, Slack, Gotify, n8n, …)
when a client submits.
</p>
)}
<CreateForm onCreated={() => void load()} />
{error && <p className="admin-error">{error}</p>}
{sessions === null && !error ? (
<div className="admin-loading-container">
<div className="admin-spinner" />
</div>
) : sessions && sessions.length === 0 ? (
<p className="analytics-empty">No client links yet.</p>
) : (
sessions?.map((session) => (
<SessionCard
key={session.id}
session={session}
expanded={expanded === session.id}
onToggle={() => setExpanded((cur) => (cur === session.id ? null : session.id))}
onChanged={() => void load()}
/>
))
)}
</div>
);
}

View File

@@ -7,16 +7,20 @@
* path through useAboutEditor().
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import SaveBar, { type SaveStatus } from './SaveBar';
import { useUnsavedGuard } from './useUnsavedGuard';
import { useDraft } from './useDraft';
import { useLatest } from './useLatest';
import DraftNotice from './DraftNotice';
import { useContentRestored } from './contentRestored';
import { reportIfSessionExpired } from './sessionExpiry';
import { useVersionedSave } from './useVersionedSave';
import type { Settings, SectionProps } from './settings/types';
import type { EnvLocks } from '@/lib/admin/envLocks';
import { validateSettingValues } from '@/lib/config/settingValues';
import GeneralSection from './settings/GeneralSection';
import ThemeSection from './settings/ThemeSection';
import GridSection from './settings/GridSection';
@@ -65,6 +69,8 @@ export default function SettingsEditor() {
effective: string | null;
source: 'env' | 'settings' | 'none';
} | null>(null);
/** Fields an environment variable overrides, path to variable name (#605). */
const [envLocks, setEnvLocks] = useState<EnvLocks>({});
const [loading, setLoading] = useState(true);
/**
* Set when the settings could not be fetched. It blocks saving, because an
@@ -87,6 +93,11 @@ export default function SettingsEditor() {
// holds the password as typed. A restored draft takes the password from the
// server instead; a new one is typed again after a reload.
const settingsDraft = useDraft<Settings>('settings', withoutSitePassword(settings), dirty);
/** The form as last rendered: tells a finished save whether editing went on meanwhile. */
const latestSettings = useLatest(settings);
/** settings.yaml as loaded, sent back on save so a change elsewhere is caught (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
useEffect(() => {
loadSettings();
@@ -149,14 +160,16 @@ export default function SettingsEditor() {
: `The server answered ${res.status}.`,
);
}
const { settings: data, siteUrl } = await res.json();
const { settings: data, siteUrl, envLocks: locks, version } = await res.json();
const loaded: Settings = data || {};
versionRef.current = typeof version === 'string' ? version : null;
setServerSettings(loaded);
const restoredDraft = settingsDraft.load(JSON.stringify(withoutSitePassword(loaded)));
const restored = restoredDraft && withSitePasswordOf(restoredDraft, loaded);
setSettings(restored ?? loaded);
setDirty(restored !== null);
setSiteUrlInfo(siteUrl ?? null);
setEnvLocks(locks ?? {});
} catch (err) {
console.error('Failed to load settings:', err);
setLoadError(err instanceof Error ? err.message : 'The settings could not be loaded.');
@@ -218,8 +231,9 @@ export default function SettingsEditor() {
setSaving(true);
setSaveStatus(null);
const sent = settings;
// Clean up empty objects
const cleaned = JSON.parse(JSON.stringify(settings));
const cleaned = JSON.parse(JSON.stringify(sent));
for (const key of Object.keys(cleaned)) {
if (typeof cleaned[key] === 'object' && Object.keys(cleaned[key]).length === 0) {
delete cleaned[key];
@@ -227,39 +241,56 @@ export default function SettingsEditor() {
}
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: cleaned }),
});
const result = await versionedSave(
'/api/admin/settings',
{ settings: cleaned },
versionRef,
'The settings file',
);
if (res.ok) {
const data = await res.json();
if (result.kind === 'reload') {
settingsDraft.discard();
await loadSettings();
} else if (result.kind === 'keep') {
setSaveStatus({ kind: 'error', message: 'Not saved — the settings changed elsewhere.' });
} else if (result.kind === 'saved') {
const data = result.data;
// The site password is stored hashed (#690); show that, not the typed text.
const stored: Settings =
typeof data.sitePassword === 'string'
? { ...cleaned, sitePassword: data.sitePassword }
: cleaned;
// Edited while the request was out: those edits are not saved yet, so
// they stay dirty, and a password retyped meanwhile is not replaced.
const editedMeanwhile = latestSettings.current !== sent;
if (typeof data.sitePassword === 'string') {
setSettings((s) => ({ ...s, sitePassword: data.sitePassword }));
const hashed = data.sitePassword;
setSettings((s) =>
s.sitePassword === sent.sitePassword ? { ...s, sitePassword: hashed } : s,
);
}
setServerSettings(stored);
settingsDraft.saved(JSON.stringify(withoutSitePassword(stored)));
setDirty(false);
setSaveStatus({ kind: 'success', message: data.message || 'Saved!' });
settingsDraft.saved(JSON.stringify(withoutSitePassword(stored)), editedMeanwhile);
if (!editedMeanwhile) setDirty(false);
setSaveStatus({
kind: 'success',
message: typeof data.message === 'string' ? data.message : 'Saved!',
});
router.refresh();
setTimeout(() => setSaveStatus(null), 5000);
} else if (!reportIfSessionExpired(res)) {
const err = await res.json();
} else if (!reportIfSessionExpired(result.res)) {
const err = result.data ?? {};
// A rejected save names the fields that caused it. Listing them beats
// "could not be saved" over a form with forty inputs; putting the
// message next to each input is the job of #600.
// "could not be saved" over a form with forty inputs; the value
// checks also stand next to their inputs (fieldErrors).
const fields: string[] = Array.isArray(err.fields)
? err.fields.map((f: { field?: string }) => f.field || 'settings')
? err.fields.map((f: { field?: string; message?: string }) =>
f.message ? `${f.field || 'settings'}: ${f.message}` : f.field || 'settings',
)
: [];
setSaveStatus({
kind: 'error',
message: `Error: ${err.error}${fields.length ? ` — ${fields.join(', ')}` : ''}`,
message: `Error: ${err.error ?? `HTTP ${result.res.status}`}${fields.length ? ` — ${fields.join(', ')}` : ''}`,
});
}
} catch {
@@ -311,7 +342,10 @@ export default function SettingsEditor() {
const saveBarStatus: SaveStatus =
[saveStatus, about.status].find((st) => st?.kind === 'error') ?? saveStatus ?? about.status;
const props: SectionProps = { settings, update, updateMany };
const fieldErrors = Object.fromEntries(
validateSettingValues(settings).map((e) => [e.field, e.message]),
);
const props: SectionProps = { settings, update, updateMany, envLocks, fieldErrors };
return (
<div className="settings-editor">

View File

@@ -0,0 +1,94 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import { JournalEditor } from '../journal/JournalEditor';
import { NotificationProvider } from '../Notifications';
import { ConfirmProvider } from '../ConfirmDialog';
/**
* The page editor's Publish button flips the Draft flag and saves at once
* (#722). When that save fails, the flipped flag is an unsaved edit: the editor
* has to say so and keep it saveable, not show "Saved" over a page that is
* still a draft on disk.
*/
const MARKDOWN = '---\ntitle: "FAQ"\ndraft: true\n---\n\nHello\n';
let putStatus = 500;
beforeEach(() => {
putStatus = 500;
vi.stubGlobal(
'fetch',
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
if (putStatus !== 200) {
return new Response(JSON.stringify({ error: 'Failed to save page' }), {
status: putStatus,
});
}
const sent = JSON.parse(init.body as string) as { rawMarkdown: string };
return new Response(
JSON.stringify({
success: true,
page: { slug: 'faq', rawMarkdown: sent.rawMarkdown },
version: 'v2',
}),
{ status: 200 },
);
}
return new Response(
JSON.stringify({ page: { slug: 'faq', rawMarkdown: MARKDOWN }, version: 'v1' }),
{ status: 200 },
);
}),
);
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the editor a working one.
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
// Drafts live in sessionStorage; each test starts without one.
window.sessionStorage.clear();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function renderEditor() {
return render(
<NotificationProvider>
<ConfirmProvider>
<JournalEditor slug="faq" kind="page" onBack={() => {}} />
</ConfirmProvider>
</NotificationProvider>,
);
}
describe('JournalEditor page mode: Publish', () => {
it('leaves a failed publish as an unsaved change', async () => {
renderEditor();
fireEvent.click(await screen.findByRole('button', { name: 'Publish' }));
await waitFor(() =>
expect(screen.getByRole('alert').textContent).toContain('Failed to save page'),
);
const save = screen.getByRole('button', { name: 'Save Changes' });
expect((save as HTMLButtonElement).disabled).toBe(false);
});
it('reads Saved after a publish that went through', async () => {
putStatus = 200;
renderEditor();
fireEvent.click(await screen.findByRole('button', { name: 'Publish' }));
await screen.findByRole('button', { name: 'Unpublish' });
await waitFor(() => expect(screen.getByRole('button', { name: /Saved/ })).toBeTruthy());
});
});

View File

@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import PrivacyEditor from '../PrivacyEditor';
import { NotificationProvider } from '../Notifications';
import { ConfirmProvider } from '../ConfirmDialog';
/**
* The privacy editor sits in Settings → Legal and unmounts when another
* section opens. An unsaved policy used to go with it (A-7); it now keeps a
* draft like the About editor (#554, #592).
*/
let serverBody = 'Saved policy';
beforeEach(() => {
serverBody = 'Saved policy';
window.sessionStorage.clear();
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
JSON.stringify({
body: serverBody,
enabled: true,
facts: [],
starter: '## Controller',
version: 'v1',
}),
{ status: 200 },
),
),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function mount() {
return render(
<NotificationProvider>
<ConfirmProvider>
<PrivacyEditor />
</ConfirmProvider>
</NotificationProvider>,
);
}
const editor = async () =>
(await screen.findByLabelText('Privacy policy (Markdown)')) as HTMLTextAreaElement;
describe('PrivacyEditor draft (A-7)', () => {
it('brings back unsaved text after the editor was unmounted', async () => {
const first = mount();
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
first.unmount();
mount();
expect((await editor()).value).toBe('Unsaved policy');
expect(screen.getByText(/unsaved changes to the privacy policy were restored/)).toBeTruthy();
const save = screen.getByRole('button', { name: 'Save privacy policy' }) as HTMLButtonElement;
expect(save.disabled).toBe(false);
});
it('discarding the draft shows the saved policy again', async () => {
const first = mount();
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
first.unmount();
mount();
await editor();
fireEvent.click(screen.getByRole('button', { name: 'Discard changes' }));
expect((await editor()).value).toBe('Saved policy');
await waitFor(() => expect(window.sessionStorage.getItem('folio_draft_privacy')).toBeNull());
});
it('holds a draft back when the file changed since, and asks', async () => {
const first = mount();
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
first.unmount();
serverBody = 'Changed in another tab';
mount();
expect((await editor()).value).toBe('Changed in another tab');
fireEvent.click(screen.getByRole('button', { name: 'Restore my changes' }));
expect((await editor()).value).toBe('Unsaved policy');
});
});

View File

@@ -0,0 +1,176 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { useModalDialog } from '@/hooks/useModalDialog';
import AlbumPicker from '../AlbumPicker';
import AlbumDrawer from '../page-builder/AlbumDrawer';
import { StorySettingsModal } from '../journal/StorySettingsModal';
afterEach(() => {
cleanup();
});
function Dialog({
name,
onClose,
children,
}: {
name: string;
onClose: () => void;
children?: React.ReactNode;
}) {
const ref = useModalDialog(onClose);
return (
<div ref={ref} role="dialog" aria-modal="true" aria-label={name}>
<button type="button">{name} first</button>
{children}
<button type="button">{name} last</button>
</div>
);
}
const escape = () => fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
describe('useModalDialog with nested dialogs (A-9)', () => {
it('Escape closes only the innermost dialog', () => {
const closeOuter = vi.fn();
const closeInner = vi.fn();
const { rerender } = render(<Dialog name="outer" onClose={closeOuter} />);
rerender(
<Dialog name="outer" onClose={closeOuter}>
<Dialog name="inner" onClose={closeInner} />
</Dialog>,
);
escape();
expect(closeInner).toHaveBeenCalledTimes(1);
expect(closeOuter).not.toHaveBeenCalled();
// Once the inner one is gone, the outer one answers again.
rerender(<Dialog name="outer" onClose={closeOuter} />);
escape();
expect(closeOuter).toHaveBeenCalledTimes(1);
});
it('leaves Escape to an expanded combobox', () => {
const close = vi.fn();
render(
<Dialog name="d" onClose={close}>
<button type="button" role="combobox" aria-expanded="true" aria-controls="x">
sort
</button>
</Dialog>,
);
screen.getByRole('combobox').focus();
escape();
expect(close).not.toHaveBeenCalled();
});
it('leaves Escape to an item picked up with the keyboard (dnd-kit)', () => {
const close = vi.fn();
render(
<Dialog name="d" onClose={close}>
<div role="button" tabIndex={0} aria-roledescription="sortable" aria-pressed="true">
tile
</div>
</Dialog>,
);
screen.getByText('tile').focus();
escape();
expect(close).not.toHaveBeenCalled();
});
});
describe('AlbumPicker from the keyboard (A-10)', () => {
const albums = [
{
id: 'a1',
albumName: 'Alps',
description: '',
thumbnailAssetId: null,
assetCount: 3,
isConfigured: false,
},
{
id: 'a2',
albumName: 'Baltic',
description: '',
thumbnailAssetId: null,
assetCount: 5,
isConfigured: true,
},
];
it('offers each free album as a button and disables the ones in use', () => {
const onSelect = vi.fn();
render(
<AlbumPicker
albums={albums}
onSelect={onSelect}
onClose={() => {}}
usedAlbumIds={new Set(['a2'])}
/>,
);
const alps = screen.getByRole('button', { name: /Alps/ });
const baltic = screen.getByRole('button', { name: /Baltic/ }) as HTMLButtonElement;
expect(baltic.disabled).toBe(true);
fireEvent.click(alps);
expect(onSelect).toHaveBeenCalledWith('a1');
expect(screen.getByRole('button', { name: 'Close' })).toBeTruthy();
});
it('closes on Escape', () => {
const onClose = vi.fn();
render(
<AlbumPicker
albums={albums}
onSelect={() => {}}
onClose={onClose}
usedAlbumIds={new Set()}
/>,
);
escape();
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe('AlbumDrawer as a dialog (A-9)', () => {
it('is a labelled modal dialog that closes on Escape', () => {
const onClose = vi.fn();
render(
<AlbumDrawer
album={{ id: 'a1' }}
name="Alps"
count={3}
thumbnailId={null}
onUpdate={() => {}}
onRemove={() => {}}
onClose={onClose}
onPickHero={() => {}}
onEditOrder={() => {}}
/>,
);
const dialog = screen.getByRole('dialog', { name: /Edit Album Details/ });
expect(dialog.getAttribute('aria-modal')).toBe('true');
expect(dialog.contains(document.activeElement)).toBe(true);
escape();
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe('StorySettingsModal as a dialog (A-9)', () => {
it('is a labelled modal dialog that closes on Escape', () => {
const onClose = vi.fn();
render(
<StorySettingsModal
frontmatter={{ title: 'Trip' }}
onChange={() => {}}
onPickAsset={() => {}}
onClose={onClose}
/>,
);
const dialog = screen.getByRole('dialog', { name: /Story Settings/ });
expect(dialog.getAttribute('aria-modal')).toBe('true');
escape();
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,266 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor, act } from '@testing-library/react';
import { JournalEditor } from '../journal/JournalEditor';
import PrivacyEditor from '../PrivacyEditor';
import SettingsEditor from '../SettingsEditor';
import PageBuilder from '../PageBuilder';
import { NotificationProvider } from '../Notifications';
import { ConfirmProvider } from '../ConfirmDialog';
const nav = vi.hoisted(() => ({ section: 'general' }));
vi.mock('next/navigation', () => ({
useRouter: () => ({ refresh: () => {}, push: () => {}, replace: () => {} }),
useParams: () => ({ section: nav.section }),
usePathname: () => '/admin/settings/general',
}));
/**
* A save handler closes over the state it started with. Typing on while the
* request is out used to be marked saved when the answer came back: the Save
* button went to "Saved" and disabled, the unsaved-changes guard and the draft
* let go, and leaving the editor threw those edits away. Where the server
* rewrote the text (a hashed password line), the newer text was also replaced
* by the file as written.
*/
/** A PUT that answers only when the test says so. */
let releasePut: ((written?: string) => void) | null = null;
let putBodies: unknown[] = [];
function stubFetch(
loadResponse: unknown,
putResponse: (sent: Record<string, unknown>, written?: string) => unknown,
) {
vi.stubGlobal(
'fetch',
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
const sent = JSON.parse(init.body as string) as Record<string, unknown>;
putBodies.push(sent);
const written = await new Promise<string | undefined>((resolve) => {
releasePut = resolve;
});
return new Response(JSON.stringify(putResponse(sent, written)), { status: 200 });
}
const body = typeof loadResponse === 'function' ? loadResponse(_url) : loadResponse;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
beforeEach(() => {
nav.section = 'general';
releasePut = null;
putBodies = [];
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the editor a working one.
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
window.sessionStorage.clear();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const ENTRY = '---\ntitle: "Trip"\n---\n\nHello\n';
async function renderJournal() {
stubFetch({ entry: { slug: 'trip', rawMarkdown: ENTRY }, version: 'v1' }, (sent, written) => ({
success: true,
entry: { slug: 'trip', rawMarkdown: written ?? sent.rawMarkdown },
version: 'v2',
}));
render(
<NotificationProvider>
<ConfirmProvider>
<JournalEditor slug="trip" onBack={() => {}} />
</ConfirmProvider>
</NotificationProvider>,
);
fireEvent.click(await screen.findByRole('button', { name: 'Raw Markdown' }));
return screen.getByPlaceholderText('Write Markdown here...') as HTMLTextAreaElement;
}
describe('JournalEditor: edits made while a save is in flight', () => {
it('stay unsaved and saveable', async () => {
const editor = await renderJournal();
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => expect(releasePut).not.toBeNull());
fireEvent.change(editor, { target: { value: `${ENTRY}First\nSecond\n` } });
await act(async () => releasePut!());
await waitFor(() => expect(screen.queryByRole('button', { name: 'Saving...' })).toBeNull());
expect(editor.value).toBe(`${ENTRY}First\nSecond\n`);
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
expect(save.disabled).toBe(false);
// Still kept as a draft, so leaving the editor does not lose it.
expect(window.sessionStorage.getItem('folio_draft_journal-trip')).toContain('Second');
});
it('are not replaced by the file as written', async () => {
const editor = await renderJournal();
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => expect(releasePut).not.toBeNull());
fireEvent.change(editor, { target: { value: `${ENTRY}First\nSecond\n` } });
// The server rewrote what it was sent, as it does when it hashes a password.
await act(async () => releasePut!(`${ENTRY}First (as written)\n`));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Saving...' })).toBeNull());
expect(editor.value).toBe(`${ENTRY}First\nSecond\n`);
});
it('a save with no edits meanwhile still ends clean, showing the file as written', async () => {
const editor = await renderJournal();
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => expect(releasePut).not.toBeNull());
await act(async () => releasePut!(`${ENTRY}First (as written)\n`));
await screen.findByRole('button', { name: /Saved/ });
expect(editor.value).toBe(`${ENTRY}First (as written)\n`);
expect(window.sessionStorage.getItem('folio_draft_journal-trip')).toBeNull();
});
});
describe('PrivacyEditor: edits made while a save is in flight', () => {
it('stay unsaved and saveable', async () => {
stubFetch(
{ body: 'Old', enabled: true, facts: [], starter: '## Controller', version: 'v1' },
() => ({ success: true, version: 'v2' }),
);
render(
<NotificationProvider>
<ConfirmProvider>
<PrivacyEditor />
</ConfirmProvider>
</NotificationProvider>,
);
const editor = (await screen.findByLabelText(
'Privacy policy (Markdown)',
)) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: 'First' } });
fireEvent.click(screen.getByRole('button', { name: 'Save privacy policy' }));
await waitFor(() => expect(releasePut).not.toBeNull());
fireEvent.change(editor, { target: { value: 'First and second' } });
await act(async () => releasePut!());
await screen.findByText('Privacy policy saved.');
const save = screen.getByRole('button', { name: 'Save privacy policy' }) as HTMLButtonElement;
expect(save.disabled).toBe(false);
expect(putBodies).toEqual([{ body: 'First' }]);
});
});
describe('SettingsEditor: edits made while a save is in flight', () => {
it('stay unsaved and saveable', async () => {
stubFetch({ settings: { title: 'Old' }, version: 'v1', envLocks: {} }, () => ({
success: true,
version: 'v2',
}));
render(
<NotificationProvider>
<ConfirmProvider>
<SettingsEditor />
</ConfirmProvider>
</NotificationProvider>,
);
const title = (await screen.findByLabelText('Site Title')) as HTMLInputElement;
fireEvent.change(title, { target: { value: 'First' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => expect(releasePut).not.toBeNull());
fireEvent.change(title, { target: { value: 'First and second' } });
await act(async () => releasePut!());
await screen.findByText('Saved!');
expect(title.value).toBe('First and second');
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
expect(save.disabled).toBe(false);
expect(window.sessionStorage.getItem('folio_draft_settings')).toContain('First and second');
});
});
describe('About editor: edits made while a save is in flight', () => {
it('stay unsaved and saveable', async () => {
nav.section = 'about';
stubFetch(
(url: string) =>
url === '/api/admin/about'
? { meta: { name: 'Ada' }, body: 'Old', version: 'v1' }
: { settings: {}, version: 'v1', envLocks: {} },
() => ({ success: true, version: 'v2' }),
);
render(
<NotificationProvider>
<ConfirmProvider>
<SettingsEditor />
</ConfirmProvider>
</NotificationProvider>,
);
const bio = (await screen.findByLabelText('Biography (Markdown)')) as HTMLTextAreaElement;
fireEvent.change(bio, { target: { value: 'First' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => expect(releasePut).not.toBeNull());
fireEvent.change(bio, { target: { value: 'First and second' } });
await act(async () => releasePut!());
await screen.findByText('Saved!');
expect(bio.value).toBe('First and second');
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
expect(save.disabled).toBe(false);
expect(window.sessionStorage.getItem('folio_draft_about')).toContain('First and second');
});
});
describe('PageBuilder: edits made while a save is in flight', () => {
it('stay unsaved and saveable, and are not replaced by the file as written', async () => {
stubFetch(
(url: string) =>
url === '/api/admin/gallery'
? { gallery: { hero: [], albums: [], subpages: [] }, version: 'v1' }
: url === '/api/admin/albums'
? { albums: [] }
: { pages: [], taken: { subpages: [], albums: [], journal: [] } },
// As written: hashed passwords make the server copy differ from the one sent.
(sent) => ({
success: true,
version: 'v2',
gallery: { ...(sent.gallery as object), hero: ['written-by-server'] },
}),
);
render(
<NotificationProvider>
<ConfirmProvider>
<PageBuilder />
</ConfirmProvider>
</NotificationProvider>,
);
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/ }));
await act(async () => releasePut!());
await screen.findByText('Saved successfully!');
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
expect(save.disabled).toBe(false);
const kept = JSON.parse(window.sessionStorage.getItem('folio_draft_page-builder') ?? 'null');
expect(kept?.value?.subpages).toHaveLength(2);
expect((putBodies[0] as { gallery: { subpages: unknown[] } }).gallery.subpages).toHaveLength(1);
});
});

View File

@@ -0,0 +1,196 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { useState } from 'react';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { JournalEditor } from '../journal/JournalEditor';
import { EssayBlockEditor } from '../EssayBlockEditor';
import { InlineMarkdownField } from '../InlineMarkdownField';
import { NotificationProvider } from '../Notifications';
import { ConfirmProvider } from '../ConfirmDialog';
/**
* Text blocks are edited as markdown, not as the HTML the parser stores (A-5).
* A reloaded entry used to show `<strong>` and `<a href=…>` in the textarea,
* text typed into a tag was lost on save, and typed `**bold**` stayed literal
* in the live preview until the first save.
*/
const ID = '3f1c9a52-8d4e-4b7a-9c1d-0e2f3a4b5c6d';
const ENTRY = [
'---',
'title: "Trip"',
'---',
'',
'Hello **bold** and [Immich](https://immich.app).',
'',
'> A *quoted* line -- Someone',
'',
`![${ID}:wide](A **nice** view)`,
'',
'::facts',
'Distance: **21** km',
].join('\n');
beforeEach(() => {
// The live preview's FadeIn reveals at once under reduced motion instead of
// needing IntersectionObserver.
window.matchMedia = ((query: string) => ({
matches: query.includes('reduce'),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
vi.stubGlobal(
'fetch',
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
const sent = JSON.parse(init.body as string) as { rawMarkdown: string };
return new Response(
JSON.stringify({
success: true,
entry: { slug: 'trip', rawMarkdown: sent.rawMarkdown },
version: 'v2',
}),
{ status: 200 },
);
}
return new Response(
JSON.stringify({ entry: { slug: 'trip', rawMarkdown: ENTRY }, version: 'v1' }),
{ status: 200 },
);
}),
);
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the editor a working one.
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
window.sessionStorage.clear();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
async function renderJournal() {
render(
<NotificationProvider>
<ConfirmProvider>
<JournalEditor slug="trip" onBack={() => {}} />
</ConfirmProvider>
</NotificationProvider>,
);
return (await screen.findByLabelText('Text')) as HTMLTextAreaElement;
}
/** The entry as the editor would save it, read from the Raw Markdown view. */
function rawMarkdown(): string {
fireEvent.click(screen.getByRole('button', { name: 'Raw Markdown' }));
const value = (screen.getByPlaceholderText('Write Markdown here...') as HTMLTextAreaElement)
.value;
fireEvent.click(screen.getByRole('button', { name: 'Visual Blocks' }));
return value;
}
describe('journal editor: text blocks as markdown (A-5)', () => {
it('shows the markdown source, not the stored HTML, after a load', async () => {
const text = await renderJournal();
expect(text.value).toBe('Hello **bold** and [Immich](https://immich.app).');
expect(screen.getByDisplayValue('A *quoted* line')).toBeTruthy();
expect(screen.getByDisplayValue('A **nice** view')).toBeTruthy();
expect(screen.getByDisplayValue('**21** km')).toBeTruthy();
expect(document.body.querySelector('textarea, input')).toBeTruthy();
for (const field of document.body.querySelectorAll('textarea, input')) {
expect((field as HTMLInputElement).value).not.toMatch(/<(strong|em|a)\b/);
}
});
it('renders typed markdown in the live preview before any save', async () => {
const text = await renderJournal();
fireEvent.change(text, { target: { value: 'Now **strong** text.' } });
const preview = document.querySelector('.journal-preview-frame')!;
expect(preview.innerHTML).toContain('<strong>strong</strong>');
expect(preview.textContent).not.toContain('**strong**');
// What was typed stays as typed.
expect(text.value).toBe('Now **strong** text.');
});
it('keeps text typed inside a link label or bold run', async () => {
const text = await renderJournal();
fireEvent.change(text, {
target: { value: 'Hello **bold and more** and [Immich Photos](https://immich.app).' },
});
expect(rawMarkdown()).toContain(
'Hello **bold and more** and [Immich Photos](https://immich.app).',
);
});
it('writes the same file when the content is unchanged', async () => {
const text = await renderJournal();
const original = text.value;
fireEvent.change(text, { target: { value: `${original} extra` } });
fireEvent.change(screen.getByLabelText('Text'), { target: { value: original } });
expect(rawMarkdown()).toBe(ENTRY);
});
it('keeps `__` as typed while the file gets the canonical form', async () => {
const text = await renderJournal();
fireEvent.change(text, { target: { value: 'An __underscored__ word' } });
expect(text.value).toBe('An __underscored__ word');
expect(rawMarkdown()).toContain('An **underscored** word');
});
});
describe('InlineMarkdownField', () => {
function Harness({ initial }: { initial: string }) {
const [html, setHtml] = useState(initial);
return (
<>
<InlineMarkdownField aria-label="Field" html={html} onChange={setHtml} />
<button type="button" onClick={() => setHtml('<em>replaced</em>')}>
Replace
</button>
<output data-testid="html">{html}</output>
</>
);
}
it('hands back HTML and re-reads it only on an outside change', () => {
render(<Harness initial="<strong>x</strong>" />);
const field = screen.getByLabelText('Field') as HTMLInputElement;
expect(field.value).toBe('**x**');
fireEvent.change(field, { target: { value: 'a [half](http' } });
expect(field.value).toBe('a [half](http');
fireEvent.change(field, { target: { value: 'a & b <c>' } });
expect(screen.getByTestId('html').textContent).toBe('a &amp; b &lt;c&gt;');
expect(field.value).toBe('a & b <c>');
fireEvent.click(screen.getByRole('button', { name: 'Replace' }));
expect(field.value).toBe('*replaced*');
});
});
describe('essay block editor: text blocks as markdown (A-5)', () => {
it('shows markdown and serializes an edit through the renderer', () => {
const onChange = vi.fn();
render(
<EssayBlockEditor
markdown={'Some *italic* and [a link](https://immich.app).'}
onChange={onChange}
/>,
);
const text = screen.getByDisplayValue(
'Some *italic* and [a link](https://immich.app).',
) as HTMLTextAreaElement;
fireEvent.change(text, {
target: { value: 'Some *italic* and [a longer link](https://immich.app).' },
});
expect(onChange).toHaveBeenLastCalledWith(
'Some *italic* and [a longer link](https://immich.app).',
);
});
});

View File

@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import SettingsEditor from '../SettingsEditor';
import { NotificationProvider } from '../Notifications';
import { ConfirmProvider } from '../ConfirmDialog';
const nav = vi.hoisted(() => ({ section: 'grid' }));
vi.mock('next/navigation', () => ({
useRouter: () => ({ refresh: () => {}, push: () => {}, replace: () => {} }),
useParams: () => ({ section: nav.section }),
usePathname: () => `/admin/settings/${nav.section}`,
}));
/**
* A value the site would ignore is named next to its input as it is typed,
* not only after a refused save (QA A-14).
*/
beforeEach(() => {
window.sessionStorage.clear();
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
JSON.stringify({ settings: { title: 'Folio' }, version: 'v1', envLocks: {} }),
{
status: 200,
},
),
),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function mount() {
return render(
<NotificationProvider>
<ConfirmProvider>
<SettingsEditor />
</ConfirmProvider>
</NotificationProvider>,
);
}
describe('Settings form: values the site would ignore', () => {
it('marks grid columns outside 1–6 at the input', async () => {
nav.section = 'grid';
mount();
const columns = (await screen.findByLabelText(/Columns/)) as HTMLInputElement;
expect(columns.getAttribute('aria-invalid')).toBeNull();
fireEvent.change(columns, { target: { value: '9' } });
expect(columns.getAttribute('aria-invalid')).toBe('true');
const error = document.getElementById(columns.getAttribute('aria-describedby')!);
expect(error?.textContent).toBe('Must be a whole number from 1 to 6.');
fireEvent.change(columns, { target: { value: '4' } });
expect(columns.getAttribute('aria-invalid')).toBeNull();
});
it('marks an accent that is not hex', async () => {
nav.section = 'theme';
mount();
const accent = (await screen.findByLabelText('Accent colour (hex)')) as HTMLInputElement;
fireEvent.change(accent, { target: { value: 'rot' } });
expect(accent.getAttribute('aria-invalid')).toBe('true');
expect(screen.getByText('Must be a hex colour such as #e60012.')).toBeTruthy();
});
});

View File

@@ -0,0 +1,24 @@
/**
* The days the analytics chart shows: the last `count` calendar days up to
* `today`, each one present, with 0 for a day nobody visited.
*
* The chart used to take the last 14 *recorded* days, so a quiet week simply
* vanished and "Last 14 Days" could span a month (QA A-16).
*
* Keys are UTC dates (`YYYY-MM-DD`), the way /api/analytics/track writes them,
* so `today` is read in UTC too — a local date would be a day off either side
* of midnight.
*/
export function lastDays(
days: Record<string, { pageviews?: number } | undefined>,
count: number,
today: Date,
): Array<{ date: string; pageviews: number }> {
const end = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
const out: Array<{ date: string; pageviews: number }> = [];
for (let i = count - 1; i >= 0; i--) {
const date = new Date(end - i * 86_400_000).toISOString().slice(0, 10);
out.push({ date, pageviews: days[date]?.pageviews ?? 0 });
}
return out;
}

View File

@@ -0,0 +1,20 @@
import { IconLock } from '../Icons';
/**
* The note under a field an environment variable overrides (#605). The field
* itself is rendered disabled by its section; this names the variable, so the
* operator knows where the value actually lives. Never shows the value.
*/
export default function EnvLockNote({ id, variable }: { id?: string; variable: string }) {
return (
<p id={id} className="admin-env-lock">
<span className="admin-env-lock-pill">
<IconLock size={11} /> Locked
</span>
<span>
Set by <code>{variable}</code> in the environment. Change or unset it there; edits here
would have no effect.
</span>
</p>
);
}

View File

@@ -13,12 +13,17 @@ import {
IconFolder,
IconX,
} from '../Icons';
import { isLegacyAssetRef } from './blockOps';
import { InlineMarkdownField } from '../InlineMarkdownField';
import { fillAssetSlots, isLegacyAssetRef } from './blockOps';
/** What the asset picker is opened for: its title, and where the pick goes. */
export interface AssetPickTarget {
title: string;
onSelect: (assetId: string) => void;
onSelect?: (assetId: string) => void;
/** Multi-select: several picks confirmed at once (#602). */
onSelectMany?: (assetIds: string[]) => void;
/** Most assets `onSelectMany` takes. */
max?: number;
}
interface BlockFieldsProps {
@@ -71,22 +76,26 @@ export function BlockFields({
)}
{block.type === 'paragraph' && (
<textarea
<InlineMarkdownField
multiline
aria-label="Text"
className="admin-input"
rows={3}
value={block.html}
onChange={(e) => onChange({ ...block, html: e.target.value })}
placeholder="Text (**bold**, *italic*, [link](https://…))"
html={block.html}
onChange={(html) => onChange({ ...block, html })}
/>
)}
{block.type === 'quote' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<textarea
<InlineMarkdownField
multiline
className="admin-input"
rows={2}
value={block.text}
html={block.text}
placeholder="Quote text..."
onChange={(e) => onChange({ ...block, text: e.target.value })}
onChange={(text) => onChange({ ...block, text })}
/>
<input
type="text"
@@ -176,12 +185,11 @@ export function BlockFields({
Change Photo
</button>
</div>
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Caption (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption })}
/>
</div>
</div>
@@ -242,12 +250,31 @@ export function BlockFields({
</div>
))}
</div>
<input
type="text"
<div>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
onPickAsset({
title: 'Select Photos for Pair',
max: 2,
onSelectMany: (ids) => {
// Two picks replace the pair; a single pick fills a gap.
const base = ids.length >= 2 ? ['', ''] : [...block.assetIds];
const [a, b] = fillAssetSlots(base, ids, 2);
onChange({ ...block, assetIds: [a ?? '', b ?? ''] });
},
})
}
>
<IconCamera size={12} /> Pick both photos
</button>
</div>
<InlineMarkdownField
className="admin-input"
placeholder="Shared caption for pair (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption })}
/>
</div>
)}
@@ -316,16 +343,28 @@ export function BlockFields({
>
<IconPlus size={12} /> Add photo
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
onPickAsset({
title: 'Select Photos for Grid',
onSelectMany: (ids) =>
onChange({ ...block, assetIds: fillAssetSlots(block.assetIds, ids) }),
})
}
>
<IconCamera size={12} /> Pick several
</button>
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
Three or more photos, laid out in rows of three.
</span>
</div>
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Shared caption for grid (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption })}
/>
</div>
)}
@@ -348,17 +387,14 @@ export function BlockFields({
})
}
/>
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Value, e.g. 21 km"
value={item.value}
onChange={(e) =>
html={item.value}
onChange={(value) =>
onChange({
...block,
items: block.items.map((it, i) =>
i === fIdx ? { ...it, value: e.target.value } : it,
),
items: block.items.map((it, i) => (i === fIdx ? { ...it, value } : it)),
})
}
/>
@@ -467,17 +503,11 @@ export function BlockFields({
</select>
</label>
</div>
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Caption for the set (optional)"
value={block.caption || ''}
onChange={(e) =>
onChange({
...block,
caption: e.target.value || undefined,
})
}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption: caption || undefined })}
/>
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
Photos follow the album&apos;s order (a manual order in the gallery comes first). Count
@@ -494,12 +524,11 @@ export function BlockFields({
enabled.
</p>
)}
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Caption, e.g. Busan → Seoul (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption })}
/>
{block.items.map((item, mIdx) => {

View File

@@ -28,14 +28,17 @@ import type { AlbumAssetRef } from '@/lib/journalAlbum';
import { BlockBadge } from '../BlockBadge';
import { useUnsavedGuard } from '../useUnsavedGuard';
import { useDraft } from '../useDraft';
import { useLatest } from '../useLatest';
import DraftNotice from '../DraftNotice';
import { reportIfSessionExpired } from '../sessionExpiry';
import { useContentRestored } from '../contentRestored';
import { useVersionedSave } from '../useVersionedSave';
import './journal-studio.css';
import { BlockFields, type AssetPickTarget } from './BlockFields';
import { StorySettingsModal } from './StorySettingsModal';
import { PageSettingsPanel } from './PageSettingsPanel';
import { JournalPreview } from './JournalPreview';
import { createBlock, moveBlock } from './blockOps';
import { createBlock, createPhotoBlocks, moveBlock } from './blockOps';
import { useSplitPane, SPLIT_MIN, SPLIT_MAX } from './splitPane';
import { useNotify } from '../Notifications';
@@ -43,9 +46,18 @@ interface JournalEditorProps {
slug: string;
mapEnabled?: boolean;
onBack: () => void;
/**
* `page` edits a content page (#722) instead of a journal entry: its own
* API and live URL, no map block, a settings side panel instead of the
* story settings, and a Publish button that saves the Draft flag at once.
*/
kind?: 'journal' | 'page';
}
export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps) {
export function JournalEditor({ slug, mapEnabled, onBack, kind = 'journal' }: JournalEditorProps) {
const isPage = kind === 'page';
const apiUrl = isPage ? `/api/admin/pages/${slug}` : `/api/admin/journal/${slug}`;
const liveUrl = isPage ? `/${slug}` : `/journal/${slug}`;
const notify = useNotify();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -63,6 +75,8 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
} = useSplitPane();
const [rawMarkdown, setRawMarkdown] = useState('');
/** The markdown as last rendered: tells a finished save whether typing went on meanwhile. */
const latestMarkdown = useLatest(rawMarkdown);
const [parsed, setParsed] = useState<ParsedJournal>(() => ({
frontmatter: {},
blocks: [],
@@ -81,14 +95,17 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
// Unsaved edits survive leaving the editor — the entry list, a tab link,
// back, Reload (#592). The markdown is the whole entry; blocks and
// frontmatter are parsed from it. `serverMarkdown` is what a discard restores.
const draft = useDraft<string>(`journal-${slug}`, rawMarkdown, dirty);
const draft = useDraft<string>(`${isPage ? 'page' : 'journal'}-${slug}`, rawMarkdown, dirty);
const loadDraft = draft.load;
const serverMarkdown = useRef('');
/** The file as loaded, sent back on save so a change elsewhere is caught (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
// Bumped when this entry is restored from a backup, to load it again.
const [reloadKey, setReloadKey] = useState(0);
useContentRestored(({ target, slug: restored }) => {
if (target === 'journal' && restored === slug) setReloadKey((k) => k + 1);
if (target === (isPage ? 'pages' : 'journal') && restored === slug) setReloadKey((k) => k + 1);
});
// Load entry
@@ -97,7 +114,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
setLoading(true);
setLoadError(null);
try {
const res = await fetch(`/api/admin/journal/${slug}`);
const res = await fetch(apiUrl);
if (!res.ok) {
throw new Error(
res.status === 401
@@ -106,7 +123,8 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
);
}
const data = await res.json();
const md: string = data.entry.rawMarkdown;
versionRef.current = typeof data.version === 'string' ? data.version : null;
const md: string = (isPage ? data.page : data.entry).rawMarkdown;
serverMarkdown.current = md;
const restored = loadDraft(md);
setRawMarkdown(restored ?? md);
@@ -122,7 +140,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
}
}
load();
}, [slug, loadDraft, reloadKey]);
}, [slug, apiUrl, isPage, loadDraft, reloadKey]);
// Update markdown and sync blocks
const handleMarkdownChange = (newMd: string) => {
@@ -159,44 +177,61 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
setDirty(true);
};
// Save
const handleSave = async () => {
// Save. `override` is markdown to save instead of the editor's — the page
// editor's Publish button changes the Draft flag and saves in one go.
const handleSave = async (override?: string) => {
// The editor is not rendered in this state, but Cmd+S still reaches here.
if (loadError) return;
// Nothing to save: every save rotates a backup, so repeated Cmd+S on an
// unchanged entry pushed real history out of the ten kept per file.
if (!dirty || saving) return;
if ((!dirty && override === undefined) || saving) return;
const toSave = override ?? rawMarkdown;
setSaving(true);
try {
const res = await fetch(`/api/admin/journal/${slug}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rawMarkdown,
}),
});
const result = await versionedSave(
apiUrl,
{ rawMarkdown: toSave },
versionRef,
isPage ? 'This page' : 'This entry',
);
if (res.ok) {
if (result.kind === 'reload') {
draft.discard();
setReloadKey((k) => k + 1);
} else if (result.kind === 'keep') {
notify('error', 'Not saved — it changed elsewhere. Your edits are still here.');
} else if (result.kind === 'saved') {
// The file as written, not as sent: a password line is hashed on the
// way to disk (#690). Taking the sent text as the base would make the
// next draft look like a conflicting edit from elsewhere.
const data = await res.json().catch(() => null);
const data = result.data as {
page?: { rawMarkdown?: unknown };
entry?: { rawMarkdown?: unknown };
};
const record = isPage ? data.page : data.entry;
const written: string =
typeof data?.entry?.rawMarkdown === 'string' ? data.entry.rawMarkdown : rawMarkdown;
typeof record?.rawMarkdown === 'string' ? record.rawMarkdown : toSave;
serverMarkdown.current = written;
draft.saved(written);
if (written !== rawMarkdown) {
setRawMarkdown(written);
setParsed(parseJournalMarkdown(written));
// 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 = latestMarkdown.current !== toSave;
draft.saved(written, editedMeanwhile);
if (!editedMeanwhile) {
if (written !== toSave) {
setRawMarkdown(written);
setParsed(parseJournalMarkdown(written));
}
setDirty(false);
}
setDirty(false);
} else if (!reportIfSessionExpired(res)) {
const data = await res.json();
notify('error', data.error || 'Failed to save');
} else if (!reportIfSessionExpired(result.res)) {
notify('error', result.data?.error || 'Failed to save');
}
} catch {
notify('error', 'Could not save the entry. Check the connection and try again.');
notify(
'error',
`Could not save the ${isPage ? 'page' : 'entry'}. Check the connection and try again.`,
);
} finally {
setSaving(false);
}
@@ -278,6 +313,22 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
};
// Block manipulation
/** Page mode: publish or unpublish, saved at once. Same flag as the Draft pill. */
const handleTogglePublish = () => {
const updated: ParsedJournal = {
...parsed,
frontmatter: { ...parsed.frontmatter, draft: !parsed.frontmatter.draft },
};
const serialized = serializeJournalMarkdown(updated);
setParsed(updated);
setRawMarkdown(serialized);
// An edit like any other until the save lands: a failed or refused save
// must leave the flipped flag unsaved, not shown as "Saved" (a success
// clears it again).
setDirty(true);
handleSave(serialized);
};
const handleAddBlock = (type: JournalBlock['type']) => {
handleBlocksChange([...parsed.blocks, createBlock(type)]);
};
@@ -304,7 +355,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
if (loading) {
return (
<div style={{ padding: '4rem', textAlign: 'center', opacity: 0.6 }}>
Opening Journal Studio...
{isPage ? 'Opening page editor...' : 'Opening Journal Studio...'}
</div>
);
}
@@ -316,13 +367,13 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
if (loadError) {
return (
<div className="admin-error" role="alert">
<strong>This entry could not be loaded.</strong> {loadError}
<strong>This {isPage ? 'page' : 'entry'} could not be loaded.</strong> {loadError}
<p>
Nothing has been changed. Saving stays disabled until it loads, so an empty editor cannot
overwrite the entry.
overwrite it.
</p>
<button className="admin-btn admin-btn-secondary" onClick={onBack}>
Back to entries
{isPage ? 'Back to pages' : 'Back to entries'}
</button>
</div>
);
@@ -338,15 +389,15 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
className="admin-btn admin-btn-sm admin-btn-secondary"
onClick={onBack}
>
← All Entries
{isPage ? '← Pages' : '← All Entries'}
</button>
<input
type="text"
className="journal-editor-title-input"
value={parsed.frontmatter.title || ''}
placeholder="Story Title..."
aria-label="Story title"
placeholder={isPage ? 'Page title...' : 'Story Title...'}
aria-label={isPage ? 'Page title' : 'Story title'}
onChange={(e) => handleFrontmatterChange({ title: e.target.value })}
/>
@@ -382,11 +433,27 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
className="admin-btn admin-btn-sm admin-btn-secondary"
onClick={() => setShowMetaModal(true)}
>
<IconGear size={14} /> Story Settings
<IconGear size={14} /> {isPage ? 'Page Settings' : 'Story Settings'}
</button>
{isPage && (
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-secondary"
onClick={handleTogglePublish}
disabled={saving}
title={
parsed.frontmatter.draft
? 'Publish the page and save it now'
: 'Turn the page back into a draft and save it now'
}
>
{parsed.frontmatter.draft ? 'Publish' : 'Unpublish'}
</button>
)}
<a
href={`/journal/${slug}`}
href={liveUrl}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-sm admin-btn-secondary"
@@ -397,7 +464,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-primary"
onClick={handleSave}
onClick={() => handleSave()}
disabled={saving || !dirty}
>
{saving ? (
@@ -415,7 +482,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
<DraftNotice
status={draft.status}
subject="entry"
subject={isPage ? 'page' : 'entry'}
onDiscard={discardDraft}
onRestore={restoreConflictingDraft}
onDismiss={draft.dismiss}
@@ -477,6 +544,19 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
>
<IconCamera size={13} /> + Photo
</button>
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-primary"
onClick={() =>
setAssetPickerTarget({
title: 'Add Photos (one block each)',
onSelectMany: (ids) =>
handleBlocksChange([...parsed.blocks, ...createPhotoBlocks(ids)]),
})
}
>
<IconCamera size={13} /> + Photos
</button>
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-primary"
@@ -498,13 +578,16 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
>
<IconFolder size={13} /> + Album
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() => handleAddBlock('map')}
>
<IconMap size={13} /> + Map
</button>
{/* No map block on content pages in v1 (#722). */}
{!isPage && (
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() => handleAddBlock('map')}
>
<IconMap size={13} /> + Map
</button>
)}
</div>
{/* Blocks List */}
@@ -591,8 +674,17 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
<JournalPreview parsed={parsed} albumAssets={albumAssets} />
</div>
{/* Page settings: a side panel, so editing never means going back (#722). */}
{showMetaModal && isPage && (
<PageSettingsPanel
frontmatter={parsed.frontmatter}
onChange={handleFrontmatterChange}
onClose={() => setShowMetaModal(false)}
/>
)}
{/* Metadata Modal */}
{showMetaModal && (
{showMetaModal && !isPage && (
<StorySettingsModal
frontmatter={parsed.frontmatter}
onChange={handleFrontmatterChange}
@@ -618,10 +710,18 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
{assetPickerTarget && (
<AssetPicker
title={assetPickerTarget.title}
max={assetPickerTarget.max}
onSelect={(id) => {
assetPickerTarget.onSelect(id);
assetPickerTarget.onSelect?.(id);
setAssetPickerTarget(null);
}}
onSelectMany={
assetPickerTarget.onSelectMany &&
((ids) => {
assetPickerTarget.onSelectMany?.(ids);
setAssetPickerTarget(null);
})
}
onClose={() => setAssetPickerTarget(null)}
/>
)}

View File

@@ -3,7 +3,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { JournalEntrySummary } from '@/lib/journal';
import { serializeJournalMarkdown, sanitizeSlug } from '@/lib/journal';
import { serializeJournalMarkdown, sanitizeSlug, slugFieldValue } from '@/lib/journal';
import { JOURNAL_TEMPLATES } from '@/lib/journalTemplates';
import {
IconTrash,
@@ -22,6 +22,7 @@ import { useContentRestored } from '../contentRestored';
import AdminLoadState from '../AdminLoadState';
import PageHeader from '../PageHeader';
import { useConfirm } from '../ConfirmDialog';
import { useModalDialog } from '@/hooks/useModalDialog';
interface JournalStudioProps {
/** Entry to open, taken from the /admin/journal/[slug] route. */
@@ -50,6 +51,11 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
const [creating, setCreating] = useState(false);
/** null = start blank, matching the previous (only) behavior. */
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const closeCreateModal = () => {
setShowCreateModal(false);
setSelectedTemplateId(null);
};
const createCardRef = useModalDialog(closeCreateModal, showCreateModal);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
@@ -254,8 +260,16 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
{/* Create Modal */}
{showCreateModal && (
<div className="journal-modal-overlay">
<div className="journal-modal-card">
<h3 style={{ margin: '0 0 1rem' }}>Create New Journal Entry</h3>
<div
className="journal-modal-card"
ref={createCardRef}
role="dialog"
aria-modal="true"
aria-labelledby="journal-new-dialog-title"
>
<h3 id="journal-new-dialog-title" style={{ margin: '0 0 1rem' }}>
Create New Journal Entry
</h3>
<form onSubmit={handleCreate}>
<div style={{ marginBottom: '1rem' }}>
<label
@@ -304,13 +318,13 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
className="admin-input"
placeholder="e.g. expedition-nordkap"
value={newSlug}
onChange={(e) => setNewSlug(sanitizeSlug(e.target.value))}
onChange={(e) => setNewSlug(slugFieldValue(e.target.value))}
required
/>
<span
style={{ fontSize: '0.75rem', opacity: 0.6, marginTop: '2px', display: 'block' }}
>
Will be accessible at /journal/{newSlug || 'slug'}
Will be accessible at /journal/{newSlug ? sanitizeSlug(newSlug) : 'slug'}
</span>
</div>
@@ -352,10 +366,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={() => {
setShowCreateModal(false);
setSelectedTemplateId(null);
}}
onClick={closeCreateModal}
>
Cancel
</button>

View File

@@ -0,0 +1,82 @@
'use client';
import { useEffect } from 'react';
import type { ParsedJournal } from '@/lib/journal';
import PasswordField from '../fields/PasswordField';
import { IconX } from '../Icons';
type Frontmatter = ParsedJournal['frontmatter'];
interface PageSettingsPanelProps {
frontmatter: Frontmatter;
onChange: (updates: Partial<Frontmatter>) => void;
onClose: () => void;
}
/**
* A content page's settings inside the editor (#722): a side panel rather than
* the journal's modal, so the preview stays in view. The slug and the menu
* place are edited in Pages, where the collision check and the menu live.
*/
export function PageSettingsPanel({ frontmatter, onChange, onClose }: PageSettingsPanelProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<aside className="page-settings-panel" aria-label="Page settings">
<div className="page-settings-panel-head">
<h3>Page settings</h3>
<button type="button" className="admin-btn-icon" onClick={onClose} title="Close">
<IconX size={14} />
</button>
</div>
<div className="admin-field">
<label htmlFor="page-settings-description">SEO description</label>
<textarea
id="page-settings-description"
rows={3}
value={frontmatter.description || ''}
placeholder="One or two sentences for search engines and link previews"
// One line in the frontmatter: a line break would end the value.
onChange={(e) => onChange({ description: e.target.value.replace(/\s*\n\s*/g, ' ') })}
/>
</div>
<button
type="button"
className="setting-row"
onClick={() => onChange({ draft: !frontmatter.draft })}
aria-pressed={!!frontmatter.draft}
>
<span className="setting-row-info">
<span className="setting-row-title">Draft</span>
<span className="setting-row-desc">
Only you can see a draft, and it stays out of the menu and the sitemap.
</span>
</span>
<span className={`switch-toggle switch-toggle--sm ${frontmatter.draft ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
<div className="admin-field">
<span className="admin-field-label">Password (optional)</span>
<PasswordField
value={frontmatter.password || undefined}
onChange={(password) => onChange({ password: password ?? '' })}
label="Page password"
/>
</div>
<p className="page-settings-panel-note">
Settings are saved with the page. The URL and the menu place are set in Pages.
</p>
</aside>
);
}

View File

@@ -3,6 +3,7 @@
import type { ParsedJournal } from '@/lib/journal';
import type { AssetPickTarget } from './BlockFields';
import PasswordField from '../fields/PasswordField';
import { useModalDialog } from '@/hooks/useModalDialog';
type Frontmatter = ParsedJournal['frontmatter'];
@@ -20,10 +21,20 @@ export function StorySettingsModal({
onPickAsset,
onClose,
}: StorySettingsModalProps) {
const cardRef = useModalDialog(onClose);
return (
<div className="journal-modal-overlay">
<div className="journal-modal-card">
<h3 style={{ margin: '0 0 1.25rem' }}>Story Settings &amp; Metadata</h3>
<div
className="journal-modal-card"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="story-settings-title"
>
<h3 id="story-settings-title" style={{ margin: '0 0 1.25rem' }}>
Story Settings &amp; Metadata
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
@@ -153,6 +164,7 @@ export function StorySettingsModal({
<div>
<label
htmlFor="story-password"
style={{
display: 'block',
fontSize: '0.8rem',
@@ -162,11 +174,15 @@ export function StorySettingsModal({
>
Password Protection (Optional)
</label>
<PasswordField
value={frontmatter.password || undefined}
onChange={(password) => onChange({ password: password ?? '' })}
label="Entry password"
/>
{/* The input takes its styles from .admin-field, as everywhere else
PasswordField is used; without it the lock icon sat on the text. */}
<div className="admin-field" style={{ marginBottom: 0 }}>
<PasswordField
id="story-password"
value={frontmatter.password || undefined}
onChange={(password) => onChange({ password: password ?? '' })}
/>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', paddingTop: '4px' }}>

View File

@@ -17,6 +17,26 @@ export function isLegacyAssetRef(assetId: string): boolean {
return assetId.length > 0 && !ASSET_UUID.test(assetId);
}
/**
* Slots after a multi-pick (#602): picked photos fill the empty slots first,
* in pick order, and the rest are appended. With `capacity` (a pair holds two)
* the list never grows past it; overflow picks are dropped.
*/
export function fillAssetSlots(slots: string[], picked: string[], capacity?: number): string[] {
const next = [...slots];
const queue = [...picked];
for (let i = 0; i < next.length && queue.length > 0; i++) {
if (!next[i]) next[i] = queue.shift()!;
}
next.push(...queue);
return capacity === undefined ? next : next.slice(0, capacity);
}
/** One photo block per picked asset, for "+ Photos" (#602). */
export function createPhotoBlocks(assetIds: string[]): JournalBlock[] {
return assetIds.map((assetId) => ({ type: 'photo', assetId, caption: '', layout: 'contained' }));
}
/** The block the "Add Block" toolbar inserts for each type. */
export function createBlock(type: JournalBlock['type']): JournalBlock {
switch (type) {

View File

@@ -104,7 +104,9 @@
.journal-list-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
/* min(): on a phone the column is narrower than 320px, and a fixed minimum
pushed every card past the right edge. */
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 1.5rem;
padding: 2rem;
}
@@ -680,6 +682,12 @@
Below 700px it wraps: back button and mode toggle, the title on a line of
its own, then the actions with Save taking the remaining width. */
@media (max-width: 700px) {
/* The workspace already keeps a gutter; a second 2rem one left the cards
under 300px wide. */
.journal-list-grid {
padding: 1.25rem 0;
}
.journal-editor-topbar {
flex-wrap: wrap;
padding: 0.75rem 1rem;
@@ -710,3 +718,43 @@
justify-content: center;
}
}
/* ── Content page settings: a side panel, not a modal (#722) ── */
.page-settings-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(360px, 100vw);
z-index: 100;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
overflow-y: auto;
background: var(--admin-bg-raised);
border-left: 1px solid var(--admin-border);
box-shadow: -12px 0 32px rgba(0, 0, 0, 0.25);
}
.page-settings-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.page-settings-panel-head h3 {
margin: 0;
font-size: 1rem;
}
.page-settings-panel textarea {
width: 100%;
resize: vertical;
}
.page-settings-panel-note {
margin: 0;
font-size: 0.75rem;
color: var(--admin-text-muted);
}

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 { useModalDialog } from '@/hooks/useModalDialog';
interface AlbumDrawerProps {
album: AlbumEntry;
@@ -36,15 +37,29 @@ export default function AlbumDrawer({
onEditOrder,
}: AlbumDrawerProps) {
const heroThumb = album.heroImage || thumbnailId;
const cardRef = useModalDialog(onClose);
return (
<div className="album-drawer-overlay open" onClick={() => onClose()}>
<div className="album-drawer" onClick={(e) => e.stopPropagation()}>
<div
className="album-drawer"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="album-drawer-title"
onClick={(e) => e.stopPropagation()}
>
<div className="album-drawer-header">
<h3>
<h3 id="album-drawer-title">
<IconCamera /> Edit Album Details
</h3>
<button className="admin-btn-icon" onClick={() => onClose()} title="Close details">
<button
type="button"
className="admin-btn-icon"
onClick={() => onClose()}
title="Close details"
aria-label="Close details"
>
<IconX size={14} />
</button>
</div>

View File

@@ -0,0 +1,134 @@
'use client';
import { useState } from 'react';
import { sanitizeSlug, slugFieldValue } from '@/lib/journal';
import { describeCollision, pageSlugCollision, type SlugTakenBy } from '@/lib/pages';
import { useModalDialog } from '@/hooks/useModalDialog';
interface NewPageDialogProps {
taken: SlugTakenBy;
existingPageSlugs: string[];
creating: boolean;
onCreate: (input: { title: string; slug: string; showInMenu: boolean }) => void;
onClose: () => void;
}
/**
* "+ New page" (#722): a title, the slug derived from it (editable), and
* whether the page goes into the menu. v1 starts every page blank.
*/
export default function NewPageDialog({
taken,
existingPageSlugs,
creating,
onCreate,
onClose,
}: NewPageDialogProps) {
const cardRef = useModalDialog(onClose);
const [title, setTitle] = useState('');
const [slug, setSlug] = useState('');
const [slugEdited, setSlugEdited] = useState(false);
const [showInMenu, setShowInMenu] = useState(true);
/** What the field shows: the typed slug, or the one derived from the title. */
const fieldSlug = slug || (title.trim() ? sanitizeSlug(title) : '');
/** What gets created: the typed slug finished, e.g. without a trailing "-". */
const effectiveSlug = slug ? sanitizeSlug(slug) : fieldSlug;
const collision = !effectiveSlug
? null
: existingPageSlugs.includes(effectiveSlug)
? `A page "${effectiveSlug}" already exists.`
: (() => {
const c = pageSlugCollision(effectiveSlug, taken);
return c ? describeCollision(effectiveSlug, c) : null;
})();
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !effectiveSlug || collision || creating) return;
onCreate({ title: title.trim(), slug: effectiveSlug, showInMenu });
};
return (
<div className="confirm-backdrop" onClick={onClose}>
<div
ref={cardRef}
className="confirm-card new-page-card"
role="dialog"
aria-modal="true"
aria-labelledby="new-page-title"
onClick={(e) => e.stopPropagation()}
>
<h2 id="new-page-title" className="confirm-title">
New page
</h2>
<form onSubmit={submit} className="new-page-form">
<div className="admin-field">
<label htmlFor="new-page-name">Title</label>
<input
id="new-page-name"
value={title}
placeholder="e.g. Pricing"
autoFocus
onChange={(e) => {
setTitle(e.target.value);
if (!slugEdited) setSlug('');
}}
/>
</div>
<div className="admin-field">
<label htmlFor="new-page-slug">URL</label>
<div className="input-slug-wrapper">
<input
id="new-page-slug"
className="subpage-name-input"
value={fieldSlug}
placeholder="pricing"
aria-describedby="new-page-slug-hint"
onChange={(e) => {
setSlugEdited(e.target.value !== '');
setSlug(slugFieldValue(e.target.value));
}}
/>
<span className="input-slug-preview">/{effectiveSlug || 'slug'}</span>
</div>
<span
id="new-page-slug-hint"
className={`admin-field-hint${collision ? ' admin-field-hint--error' : ''}`}
>
{collision ?? 'Starts blank and as a draft. You can change the URL later.'}
</span>
</div>
<button
type="button"
className="setting-row"
onClick={() => setShowInMenu(!showInMenu)}
aria-pressed={showInMenu}
>
<span className="setting-row-info">
<span className="setting-row-title">Show in menu</span>
<span className="setting-row-desc">
Adds it to the end of the menu; saved with “Save Changes”.
</span>
</span>
<span className={`switch-toggle switch-toggle--sm ${showInMenu ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
<div className="confirm-actions">
<button type="button" className="admin-btn admin-btn-ghost" onClick={onClose}>
Cancel
</button>
<button
type="submit"
className="admin-btn admin-btn-primary"
disabled={!title.trim() || !effectiveSlug || !!collision || creating}
>
{creating ? 'Creating…' : 'Create page'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
'use client';
import { useRouter } from 'next/navigation';
import { JournalEditor } from '../journal/JournalEditor';
/**
* The journal's block editor and live preview in page mode (#722). Back goes
* to Pages with the page selected again.
*/
export function PageContentEditor({ slug }: { slug: string }) {
const router = useRouter();
return (
<JournalEditor
slug={slug}
kind="page"
onBack={() => router.push(`/admin/pages?page=${encodeURIComponent(slug)}`)}
/>
);
}

View File

@@ -0,0 +1,394 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import {
parseJournalMarkdown,
sanitizeSlug,
serializeJournalMarkdown,
slugFieldValue,
type ParsedJournal,
} from '@/lib/journal';
import {
describeCollision,
pageSlugCollision,
type PageSummary,
type SlugTakenBy,
} from '@/lib/pages';
import PasswordField from '../fields/PasswordField';
import { IconFileText, IconTrash } from '../Icons';
import { useConfirm } from '../ConfirmDialog';
import { useNotify } from '../Notifications';
import { reportIfSessionExpired } from '../sessionExpiry';
import { useVersionedSave } from '../useVersionedSave';
import type { GalleryVersionChange } from '@/lib/admin/pageRefs';
interface PagePanelProps {
slug: string;
/** Whether gallery.yaml (as edited in the builder) lists the page. */
inMenu: boolean;
onToggleMenu: () => void;
/** Slugs a page may not take. */
taken: SlugTakenBy;
/** The other pages' slugs, which a rename must not take either. */
otherPageSlugs: string[];
/**
* `galleryVersion` is set when the server rewrote gallery.yaml's menu, so
* the builder can follow that change without mistaking it for a foreign
* edit (#601).
*/
onSaved: (
oldSlug: string,
page: PageSummary,
menuRenamed: boolean,
galleryVersion?: GalleryVersionChange | null,
) => void;
onDeleted: (
slug: string,
removedFromMenu: boolean,
galleryVersion?: GalleryVersionChange | null,
) => void;
}
/**
* A content page selected in the structure list (#722): its settings, saved
* to content/pages/<slug>.md with the panel's own button. "Show in menu" is
* the one exception — it edits gallery.yaml, so it goes with the builder's
* Save like every other change to the menu.
*/
export default function PagePanel({
slug,
inMenu,
onToggleMenu,
taken,
otherPageSlugs,
onSaved,
onDeleted,
}: PagePanelProps) {
const confirm = useConfirm();
const notify = useNotify();
const [parsed, setParsed] = useState<ParsedJournal | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [title, setTitle] = useState('');
const [slugInput, setSlugInput] = useState(slug);
const [draft, setDraft] = useState(false);
const [password, setPassword] = useState<string | undefined>(undefined);
const [description, setDescription] = useState('');
const [saving, setSaving] = useState(false);
/** The page file as loaded, sent back on save (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
/** Bumped to load the file again — "Reload" in the conflict prompt. */
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
setParsed(null);
setLoadError(null);
fetch(`/api/admin/pages/${encodeURIComponent(slug)}`)
.then(async (res) => {
if (!res.ok) {
reportIfSessionExpired(res);
throw new Error(
res.status === 404
? 'There is no file for this page in content/pages/.'
: `The server answered ${res.status}.`,
);
}
return res.json();
})
.then((data: { page: { rawMarkdown: string }; version?: string }) => {
if (cancelled) return;
versionRef.current = data.version ?? null;
const p = parseJournalMarkdown(data.page.rawMarkdown);
setParsed(p);
setTitle(p.frontmatter.title || '');
setSlugInput(slug);
setDraft(!!p.frontmatter.draft);
setPassword(p.frontmatter.password || undefined);
setDescription(p.frontmatter.description || '');
})
.catch((err: unknown) => {
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Could not load.');
});
return () => {
cancelled = true;
};
}, [slug, reloadKey]);
// The field keeps what is typed, a trailing "-" included; the page moves to
// the finished slug.
const targetSlug = slugInput ? sanitizeSlug(slugInput) : '';
const fm = parsed?.frontmatter;
const dirty =
!!fm &&
(title !== (fm.title || '') ||
targetSlug !== slug ||
draft !== !!fm.draft ||
(password || '') !== (fm.password || '') ||
description !== (fm.description || ''));
const renaming = targetSlug !== slug;
const collision = renaming
? otherPageSlugs.includes(targetSlug)
? `A page "${targetSlug}" already exists.`
: (() => {
const c = pageSlugCollision(targetSlug, taken);
return c ? describeCollision(targetSlug, c) : null;
})()
: null;
async function save() {
if (!parsed || !dirty || collision || !title.trim()) return;
if (renaming) {
const ok = await confirm({
title: `Move this page to /${targetSlug}?`,
message: `Links to /${slug} will stop working. There is no automatic redirect.`,
confirmLabel: 'Move page',
});
if (!ok) return;
}
setSaving(true);
try {
const markdown = serializeJournalMarkdown({
...parsed,
frontmatter: {
...parsed.frontmatter,
title: title.trim(),
draft: draft || undefined,
password: password || undefined,
description: description.trim() || undefined,
},
});
const result = await versionedSave(
`/api/admin/pages/${encodeURIComponent(slug)}`,
{ rawMarkdown: markdown, ...(renaming ? { newSlug: targetSlug } : {}) },
versionRef,
'This page',
);
if (result.kind === 'reload') {
setReloadKey((k) => k + 1);
return;
}
if (result.kind === 'keep') {
notify('error', 'Not saved — the page changed elsewhere. Your edits are still here.');
return;
}
if (result.kind === 'failed') {
if (!reportIfSessionExpired(result.res)) {
notify('error', result.data?.error || 'Failed to save the page');
}
return;
}
const data = result.data as {
page: { slug: string; rawMarkdown: string };
menuRenamed?: boolean;
galleryVersion?: GalleryVersionChange | null;
/** The page was saved, but its menu entry in gallery.yaml was not updated. */
warning?: string;
};
const written = parseJournalMarkdown(data.page.rawMarkdown);
setParsed(written);
setPassword(written.frontmatter.password || undefined);
const { title: t, description: d, password: pw, draft: dr } = written.frontmatter;
onSaved(
slug,
{
slug: data.page.slug,
frontmatter: { title: t, description: d, password: pw, draft: dr },
},
data.menuRenamed === true,
data.galleryVersion,
);
if (data.warning) notify('error', data.warning);
else notify('success', 'Page saved.');
} catch {
notify('error', 'Could not save the page. Check the connection and try again.');
} finally {
setSaving(false);
}
}
async function remove() {
const ok = await confirm({
title: `Delete “${title || slug}”?`,
message: inMenu
? 'The page is also removed from the menu in gallery.yaml. Both files are backed up first and can be restored under Backups.'
: 'A copy is kept and can be restored under Backups.',
confirmLabel: 'Delete page',
danger: true,
});
if (!ok) return;
try {
const res = await fetch(`/api/admin/pages/${encodeURIComponent(slug)}`, {
method: 'DELETE',
});
const data = await res.json().catch(() => null);
if (!res.ok) {
if (!reportIfSessionExpired(res))
notify('error', data?.error || 'Failed to delete the page');
return;
}
onDeleted(slug, data?.removedFromMenu === true, data?.galleryVersion);
if (typeof data?.warning === 'string') notify('error', data.warning);
else notify('success', 'Page deleted.');
} catch {
notify('error', 'Could not delete the page. Check the connection and try again.');
}
}
if (loadError) {
return (
<section className="builder-section">
<div className="builder-section-header">
<h2>
<IconFileText /> /{slug}
</h2>
</div>
<div className="admin-error" role="alert">
<strong>This page could not be loaded.</strong> {loadError}
{inMenu && (
<p>
The menu still lists it. Switch off “Show in menu” and save the structure to remove
the entry.
</p>
)}
</div>
{inMenu && (
<button type="button" className="admin-btn admin-btn-secondary" onClick={onToggleMenu}>
Remove from menu
</button>
)}
</section>
);
}
if (!parsed) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<section className="builder-section page-panel">
<div className="builder-section-header">
<h2>
<IconFileText /> {fm?.title || slug}
</h2>
<a
href={`/${encodeURIComponent(slug)}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-xs admin-btn-ghost"
title="Open the live page in a new tab"
>
/{slug} ↗
</a>
</div>
<div className="admin-field">
<label htmlFor="page-title">Title</label>
<input id="page-title" value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="page-slug">URL</label>
<div className="input-slug-wrapper">
<input
id="page-slug"
className="subpage-name-input"
value={slugInput}
onChange={(e) => setSlugInput(slugFieldValue(e.target.value))}
aria-describedby="page-slug-hint"
/>
<span className="input-slug-preview">/{targetSlug}</span>
</div>
<span
id="page-slug-hint"
className={`admin-field-hint${collision ? ' admin-field-hint--error' : renaming ? ' admin-field-hint--warn' : ''}`}
>
{collision
? collision
: renaming
? `Links to /${slug} will stop working. There is no automatic redirect.`
: 'Checked against subpages, albums, journal entries and built-in routes.'}
</span>
</div>
<div className="page-panel-switches">
<button type="button" className="setting-row" onClick={onToggleMenu} aria-pressed={inMenu}>
<span className="setting-row-info">
<span className="setting-row-title">Show in menu</span>
<span className="setting-row-desc">
{inMenu
? 'Listed in the header menu. Drag it in the structure list to move it.'
: 'Not in the menu, but reachable at its URL — for pages shared by link.'}{' '}
Saved with “Save Changes”.
</span>
</span>
<span className={`switch-toggle switch-toggle--sm ${inMenu ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
<button
type="button"
className="setting-row"
onClick={() => setDraft(!draft)}
aria-pressed={draft}
>
<span className="setting-row-info">
<span className="setting-row-title">Draft</span>
<span className="setting-row-desc">
Only you can see a draft. It stays out of the menu and the sitemap.
</span>
</span>
<span className={`switch-toggle switch-toggle--sm ${draft ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
</div>
<div className="admin-field">
<span className="admin-field-label">Password (optional)</span>
<PasswordField value={password} onChange={setPassword} label="Page password" />
</div>
<div className="admin-field">
<label htmlFor="page-description">SEO description</label>
<textarea
id="page-description"
rows={2}
value={description}
placeholder="One or two sentences for search engines and link previews"
onChange={(e) => setDescription(e.target.value.replace(/\s*\n\s*/g, ' '))}
/>
</div>
<div className="page-panel-actions">
<Link href={`/admin/pages/edit/${slug}`} className="admin-btn admin-btn-secondary">
Edit content →
</Link>
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={save}
disabled={!dirty || saving || !!collision || !title.trim()}
>
{saving ? 'Saving…' : 'Save page'}
</button>
<button
type="button"
className="admin-btn admin-btn-danger"
onClick={remove}
style={{ marginLeft: 'auto' }}
>
<IconTrash size={14} /> Delete
</button>
</div>
</section>
);
}

View File

@@ -6,8 +6,10 @@
*/
import { useSortable } from '@dnd-kit/sortable';
import { useDroppable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import { IconFolder, IconGripVertical, IconLock, IconPencil, IconX } from '../Icons';
import type { ReactNode } from 'react';
import { IconFileText, IconFolder, IconGripVertical, IconLock, IconPencil, IconX } from '../Icons';
import type { Subpage } from './types';
// ── Sortable Hero Tile ─────────────────────────────────────────
@@ -211,3 +213,79 @@ export function SortableSubpageRow({
</div>
);
}
// ── Sortable Page Row (#722) ───────────────────────────────────
/**
* A content page in the structure list: document icon and a PAGE tag, so it
* reads as something other than a subpage. The same row sits in the menu
* list and in "Not in menu"; `id` says which, for the drag handler.
*/
export function SortablePageRow({
id,
title,
isActive,
draft,
hasPassword,
missing,
onClick,
}: {
id: string;
title: string;
isActive: boolean;
draft?: boolean;
hasPassword?: boolean;
/** Referenced from gallery.yaml, but there is no file for it. */
missing?: boolean;
onClick: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
}}
className={`pb-row ${isActive ? 'active' : ''} ${draft || missing ? 'is-off' : ''}`}
{...attributes}
>
<span className="pb-row-grip" {...listeners} title="Drag to reorder">
<IconGripVertical size={14} />
</span>
<button type="button" className="pb-row-main" onClick={onClick} aria-current={isActive}>
<span className="pb-row-thumb">
<IconFileText size={14} />
</span>
<span className="pb-row-name">
{title}
{hasPassword && <IconLock size={11} />}
</span>
{missing ? (
<span className="pb-row-state">Missing</span>
) : draft ? (
<span className="pb-row-state">Draft</span>
) : (
<span className="pb-row-tag">Page</span>
)}
</button>
</div>
);
}
/**
* The "Not in menu" group as a drop target, so a page dragged out of the
* menu lands there even when the group is empty (#722).
*/
export function OffMenuDropZone({ id, children }: { id: string; children: ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div ref={setNodeRef} className={`pb-offmenu ${isOver ? 'is-over' : ''}`}>
{children}
</div>
);
}

View File

@@ -90,6 +90,15 @@ export const addHero = (g: GalleryState, assetId: string): GalleryState => ({
hero: [...g.hero, assetId],
});
/** Append several hero photos in pick order, skipping any already listed. */
export const addHeroes = (g: GalleryState, assetIds: string[]): GalleryState => ({
...g,
hero: [
...g.hero,
...assetIds.filter((id, i) => !g.hero.includes(id) && assetIds.indexOf(id) === i),
],
});
export const removeHero = (g: GalleryState, index: number): GalleryState => ({
...g,
hero: g.hero.filter((_, i) => i !== index),
@@ -113,6 +122,14 @@ export const addSubpage = (g: GalleryState): GalleryState => ({
export const removeSubpage = (g: GalleryState, index: number): GalleryState => ({
...g,
subpages: g.subpages.filter((_, i) => i !== index),
// A page after the removed subpage moves up with the rest of the menu.
...(g.pageRefs
? {
pageRefs: g.pageRefs.map((r) =>
r.position > index ? { ...r, position: r.position - 1 } : r,
),
}
: {}),
});
export const updateSubpage = (g: GalleryState, index: number, updates: Partial<Subpage>) =>
@@ -142,6 +159,98 @@ export const updateSection = (
updates: Partial<Section>,
) => mapSection(g, subpageIndex, sectionIndex, (sec) => ({ ...sec, ...updates }));
// ── Menu: subpages and content pages together (#722) ────────────
/** One row of the menu list: a subpage by its index, or a page by its slug. */
export type MenuItem = { kind: 'subpage'; index: number } | { kind: 'page'; slug: string };
/** The menu in the order it is saved and shown: subpages with pages interleaved. */
export function menuItems(g: GalleryState): MenuItem[] {
const refs = g.pageRefs ?? [];
const out: MenuItem[] = [];
for (let i = 0; i <= g.subpages.length; i++) {
for (const ref of refs) {
if (Math.min(ref.position, g.subpages.length) === i)
out.push({ kind: 'page', slug: ref.slug });
}
if (i < g.subpages.length) out.push({ kind: 'subpage', index: i });
}
return out;
}
/** Rebuild the state from a reordered menu list. */
function fromMenu(g: GalleryState, items: MenuItem[]): GalleryState {
const subpages: GalleryState['subpages'] = [];
const pageRefs: NonNullable<GalleryState['pageRefs']> = [];
for (const item of items) {
if (item.kind === 'subpage') subpages.push(g.subpages[item.index]);
else pageRefs.push({ slug: item.slug, position: subpages.length });
}
return { ...g, subpages, pageRefs };
}
/**
* Move a menu row from one position to another. Subpage indexes change with
* it; `followMenuMove` tells the builder where an open subpage went.
*/
export function moveMenuItem(g: GalleryState, from: number, to: number): GalleryState {
const items = menuItems(g);
if (!items[from] || !items[to]) return g;
return fromMenu(g, arrayMove(items, from, to));
}
/** The index an open subpage has after moveMenuItem(from, to). */
export function followMenuMove(
g: GalleryState,
open: number | null,
from: number,
to: number,
): number | null {
if (open === null) return null;
const moved = arrayMove(menuItems(g), from, to);
let index = 0;
for (const item of moved) {
if (item.kind !== 'subpage') continue;
if (item.index === open) return index;
index++;
}
return open;
}
export function isPageInMenu(g: GalleryState, slug: string): boolean {
return (g.pageRefs ?? []).some((r) => r.slug === slug);
}
/**
* Put a page in the menu or take it out. `at` is a menu row index to insert
* before; without one the page goes to the end of the menu.
*/
export function setPageInMenu(
g: GalleryState,
slug: string,
inMenu: boolean,
at?: number,
): GalleryState {
const without = (g.pageRefs ?? []).filter((r) => r.slug !== slug);
if (!inMenu) {
return isPageInMenu(g, slug) ? { ...g, pageRefs: without } : g;
}
if (isPageInMenu(g, slug)) return g;
const items = menuItems(g);
const index = at === undefined ? items.length : Math.max(0, Math.min(at, items.length));
items.splice(index, 0, { kind: 'page', slug });
return fromMenu(g, items);
}
/** Follow a page rename, keeping its place in the menu. */
export function renamePageRef(g: GalleryState, from: string, to: string): GalleryState {
if (!isPageInMenu(g, from)) return g;
return {
...g,
pageRefs: (g.pageRefs ?? []).map((r) => (r.slug === from ? { ...r, slug: to } : r)),
};
}
// ── Reading ─────────────────────────────────────────────────────
/** The "03 — Collection" number the page renders: counted over enabled subpages only. */

View File

@@ -14,6 +14,22 @@ export interface GalleryState {
hero: string[];
albums: AlbumEntry[];
subpages: Subpage[];
/**
* Content pages in the menu (#722): `- page: <slug>` among the subpages.
* Optional so a draft saved before pages existed still loads.
*/
pageRefs?: PageRef[];
}
/**
* A content page placed in the menu. `position` is the number of subpages in
* front of it, so the subpage list keeps its own indexes — every drawer and
* album address in the builder is one — and the menu order is rebuilt from
* both lists when saving. Refs sharing a position keep their array order.
*/
export interface PageRef {
slug: string;
position: number;
}
/**
@@ -27,8 +43,18 @@ export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
);
let subpages: Subpage[] = [];
const pageRefs: PageRef[] = [];
if (Array.isArray(raw.subpages)) {
subpages = (raw.subpages as Array<Record<string, unknown>>).map((sp) => ({
const entries = raw.subpages as Array<Record<string, unknown>>;
const subpageEntries: Array<Record<string, unknown>> = [];
for (const entry of entries) {
if (entry && typeof entry.page === 'string') {
pageRefs.push({ slug: entry.page, position: subpageEntries.length });
} else {
subpageEntries.push(entry);
}
}
subpages = subpageEntries.map((sp) => ({
name: (sp.name as string) || '',
title: sp.title as string | undefined,
subtitle: sp.subtitle as string | undefined,
@@ -77,7 +103,7 @@ export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
});
}
return { hero, albums, subpages };
return { hero, albums, subpages, pageRefs };
}
/** The gallery.yaml object for a builder state. Empty groups are left out. */
@@ -90,8 +116,9 @@ export function serializeGallery(gallery: GalleryState): Record<string, unknown>
if (gallery.albums.length > 0) {
yamlData.albums = serializeAlbumEntries(gallery.albums);
}
if (gallery.subpages.length > 0) {
yamlData.subpages = gallery.subpages.map((sp) => {
const pageRefs = gallery.pageRefs ?? [];
if (gallery.subpages.length > 0 || pageRefs.length > 0) {
const subpageEntries = gallery.subpages.map((sp) => {
const entry: Record<string, unknown> = { name: sp.name };
if (sp.title) entry.title = sp.title;
if (sp.subtitle) entry.subtitle = sp.subtitle;
@@ -124,7 +151,25 @@ export function serializeGallery(gallery: GalleryState): Record<string, unknown>
return entry;
});
yamlData.subpages = interleave(subpageEntries, pageRefs);
}
return yamlData;
}
/** Subpage entries with `{ page }` entries placed at their positions. */
function interleave(
subpageEntries: Record<string, unknown>[],
pageRefs: PageRef[],
): Record<string, unknown>[] {
const out: Record<string, unknown>[] = [];
for (let i = 0; i <= subpageEntries.length; i++) {
// A ref beyond the end (a subpage was removed) lands at the end.
for (const ref of pageRefs) {
const at = Math.min(ref.position, subpageEntries.length);
if (at === i) out.push({ page: ref.slug });
}
if (i < subpageEntries.length) out.push(subpageEntries[i]);
}
return out;
}

View File

@@ -111,7 +111,10 @@ export interface PickerTarget {
export interface HeroPickerTarget {
albumId?: string;
onSelect: (assetId: string) => void;
/** Single pick; the picker closes after it. */
onSelect?: (assetId: string) => void;
/** Multi-select: several picks confirmed at once (#602). */
onSelectMany?: (assetIds: string[]) => void;
currentAssetIds?: string[];
title?: string;
}

View File

@@ -1,10 +1,12 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Icons from '../Icons';
import type { SaveStatus } from '../SaveBar';
import { useDraft, readDraft } from '../useDraft';
import { useContentRestored } from '../contentRestored';
import { useVersionedSave } from '../useVersionedSave';
import { useLatest } from '../useLatest';
interface AboutMeta {
portrait?: string;
@@ -54,6 +56,8 @@ export function useAboutEditor(active: boolean) {
const [aboutStatus, setAboutStatus] = useState<SaveStatus>(null);
const [aboutGearText, setAboutGearText] = useState('');
const [serverAbout, setServerAbout] = useState<AboutDraft | null>(null);
/** The fields as last rendered: tell a finished save whether editing went on meanwhile. */
const latest = useLatest({ meta: aboutMeta, body: aboutBody, gearText: aboutGearText });
// Unsaved About edits survive a tab switch or Reload, like settings (#592).
const draft = useDraft<AboutDraft>(
@@ -62,6 +66,9 @@ export function useAboutEditor(active: boolean) {
aboutDirty,
);
const loadDraft = draft.load;
/** about.md as loaded, sent back on save so a change elsewhere is caught (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
const applyAbout = (value: AboutDraft) => {
setAboutMeta(value.meta);
@@ -82,6 +89,7 @@ export function useAboutEditor(active: boolean) {
);
}
const data = await res.json();
versionRef.current = typeof data.version === 'string' ? data.version : null;
const loaded: AboutDraft = {
meta: data.meta || {},
body: data.body || '',
@@ -128,22 +136,37 @@ export function useAboutEditor(active: boolean) {
}
try {
const res = await fetch('/api/admin/about', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ meta: cleanedMeta, body: aboutBody }),
});
if (res.ok) {
const data = await res.json();
const result = await versionedSave(
'/api/admin/about',
{ meta: cleanedMeta, body: aboutBody },
versionRef,
'The About page',
);
if (result.kind === 'reload') {
draft.discard();
await loadAboutContent();
} else if (result.kind === 'keep') {
setAboutStatus({ kind: 'error', message: 'Not saved — the About page changed elsewhere.' });
} else if (result.kind === 'saved') {
const data = result.data;
const saved: AboutDraft = { meta: cleanedMeta, body: aboutBody, gearText: aboutGearText };
// Edited while the request was out: those edits are not saved yet.
const now = latest.current;
const editedMeanwhile =
now.meta !== aboutMeta || now.body !== aboutBody || now.gearText !== aboutGearText;
setServerAbout(saved);
draft.saved(aboutFingerprint(saved));
setAboutDirty(false);
setAboutStatus({ kind: 'success', message: data.message || 'Saved!' });
draft.saved(aboutFingerprint(saved), editedMeanwhile);
if (!editedMeanwhile) setAboutDirty(false);
setAboutStatus({
kind: 'success',
message: typeof data.message === 'string' ? data.message : 'Saved!',
});
setTimeout(() => setAboutStatus(null), 4000);
} else {
const err = await res.json();
setAboutStatus({ kind: 'error', message: `Error: ${err.error}` });
setAboutStatus({
kind: 'error',
message: `Error: ${result.data?.error ?? `HTTP ${result.res.status}`}`,
});
}
} catch {
setAboutStatus({ kind: 'error', message: 'Error: Failed to save' });

View File

@@ -137,6 +137,23 @@ export default function GeneralSection({ settings, update, updateMany }: Section
onToggle={() => update('proofing.enabled', settings.proofing?.enabled === false)}
/>
</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
settable from here. */}
<div className="admin-field">
<label htmlFor="proofing-email">Proofing email</label>
<input
id="proofing-email"
type="email"
value={settings.proofing?.email || ''}
onChange={(e) => update('proofing.email', e.target.value)}
placeholder="Defaults to the footer contact email"
/>
<p className="admin-field-hint">
Where &ldquo;Email to photographer&rdquo; sends a selection. Falls back to the footer
contact email; when neither is set, the button is hidden.
</p>
</div>
</FeatureGroup>
<FeatureGroup

View File

@@ -7,6 +7,7 @@ import {
PHOTO_GRID_COLUMNS_MIN,
PHOTO_GRID_GAP_MAX,
} from '@/lib/config/schema';
import { FieldError } from './fields';
import type { SectionProps } from './types';
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'justified'];
@@ -131,7 +132,7 @@ function AspectRatioPreview({ value }: { value: string }) {
);
}
export default function GridSection({ settings, update }: SectionProps) {
export default function GridSection({ settings, update, fieldErrors }: SectionProps) {
return (
<div className="settings-panel">
<div className="settings-section-header">
@@ -185,7 +186,10 @@ export default function GridSection({ settings, update }: SectionProps) {
max={PHOTO_GRID_COLUMNS_MAX}
value={settings.grid?.columns ?? 3}
onChange={(e) => update('grid.columns', parseInt(e.target.value) || 3)}
aria-invalid={fieldErrors?.['grid.columns'] ? true : undefined}
aria-describedby={fieldErrors?.['grid.columns'] ? 'grid-columns-error' : undefined}
/>
<FieldError id="grid-columns-error" message={fieldErrors?.['grid.columns']} />
</div>
<div className="admin-field">
<label htmlFor="grid-gap-spacing">Gap Spacing (px)</label>
@@ -196,7 +200,10 @@ export default function GridSection({ settings, update }: SectionProps) {
max={PHOTO_GRID_GAP_MAX}
value={settings.grid?.gap ?? 12}
onChange={(e) => update('grid.gap', parseInt(e.target.value) || 0)}
aria-invalid={fieldErrors?.['grid.gap'] ? true : undefined}
aria-describedby={fieldErrors?.['grid.gap'] ? 'grid-gap-error' : undefined}
/>
<FieldError id="grid-gap-error" message={fieldErrors?.['grid.gap']} />
</div>
</div>
</div>

View File

@@ -3,10 +3,12 @@
import * as Icons from '../Icons';
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 type { SectionProps } from './types';
export default function LegalSection({ settings, update }: SectionProps) {
export default function LegalSection({ settings, update, envLocks }: 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.
const contactUrl = settings.legal?.contactUrl?.trim();
@@ -206,16 +208,19 @@ export default function LegalSection({ settings, update }: SectionProps) {
value={settings.contact?.notifyUrl || ''}
onChange={(e) => update('contact.notifyUrl', e.target.value)}
placeholder="https://ntfy.sh/your-secret-topic"
aria-invalid={notifyUrlInvalid || undefined}
aria-describedby="contact-notify-url-hint"
aria-invalid={(!notifyLock && notifyUrlInvalid) || undefined}
aria-describedby={notifyLock ? 'contact-notify-url-lock' : 'contact-notify-url-hint'}
disabled={!!notifyLock}
/>
{notifyLock && <EnvLockNote id="contact-notify-url-lock" variable={notifyLock} />}
<p
id="contact-notify-url-hint"
className={`admin-field-hint${notifyUrlInvalid ? ' admin-field-hint--error' : ''}`}
>
{notifyUrlInvalid
? 'Must start with https:// or http://.'
: 'Gets a fixed "new message" push, with nothing about the sender. Pick a topic name nobody can guess. CONTACT_NOTIFY_URL overrides this.'}
: 'Gets a fixed "new message" push, with nothing about the sender. Pick a topic name nobody can guess.' +
(notifyLock ? '' : ' CONTACT_NOTIFY_URL overrides this.')}
</p>
</div>
<div className="admin-field">

View File

@@ -3,10 +3,12 @@
import * as Icons from '../Icons';
import ToggleCard from '../fields/ToggleCard';
import PasswordField from '../fields/PasswordField';
import EnvLockNote from '../fields/EnvLockNote';
import { resolveWatermarkOpacity } from '@/lib/config/schema';
import type { SectionProps } from './types';
export default function SecuritySection({ settings, update }: SectionProps) {
export default function SecuritySection({ settings, update, envLocks }: SectionProps) {
const passwordLock = envLocks?.sitePassword;
return (
<div className="settings-panel">
<div className="settings-section-header">
@@ -27,11 +29,18 @@ export default function SecuritySection({ settings, update }: SectionProps) {
onChange={(password) => update('sitePassword', password ?? '')}
placeholder="Leave empty for a public site"
label="Site password"
disabled={!!passwordLock}
/>
{passwordLock && <EnvLockNote variable={passwordLock} />}
<p className="admin-field-hint">
Stored as a <code>scrypt:…</code> hash in <code>settings.yaml</code>, never as typed. The{' '}
<code>SITE_PASSWORD</code> environment variable overrides this field. The admin panel
keeps its own password and is never behind this gate.
Stored as a <code>scrypt:…</code> hash in <code>settings.yaml</code>, never as typed.
{!passwordLock && (
<>
{' '}
The <code>SITE_PASSWORD</code> environment variable overrides this field.
</>
)}{' '}
The admin panel keeps its own password and is never behind this gate.
</p>
</div>

View File

@@ -2,11 +2,13 @@
import * as Icons from '../Icons';
import ToggleCard from '../fields/ToggleCard';
import { FieldError } from './fields';
import type { SectionProps } from './types';
export default function SeoSection({
settings,
update,
fieldErrors,
siteUrlInfo,
}: SectionProps & {
/** Resolved site URL and its origin, so the panel can name SITE_URL (#472). */
@@ -52,7 +54,10 @@ export default function SeoSection({
value={settings.url || ''}
onChange={(e) => update('url', e.target.value)}
placeholder="https://folio.example"
aria-invalid={fieldErrors?.url ? true : undefined}
aria-describedby={fieldErrors?.url ? 'seo-site-url-error' : undefined}
/>
<FieldError id="seo-site-url-error" message={fieldErrors?.url} />
<p style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '4px' }}>
Needed for <code>sitemap.xml</code>, the feed and structured data — those are generated
without a request, so the address cannot be derived from it. Leave empty and the sitemap

View File

@@ -4,7 +4,7 @@ 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 { effectiveTheme, FeatureRow } from './fields';
import { effectiveTheme, FeatureRow, FieldError } from './fields';
import type { SectionProps } from './types';
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
@@ -264,7 +264,7 @@ function PresetSpecs({ value }: { value: string }) {
);
}
export default function ThemeSection({ settings, update }: SectionProps) {
export default function ThemeSection({ settings, update, fieldErrors }: SectionProps) {
// Read off the page on mount: the toggle previews light and dark on the
// admin itself, and the page may already be in either.
const [currentMode, setCurrentMode] = useState<'dark' | 'light'>(() =>
@@ -417,8 +417,11 @@ export default function ThemeSection({ settings, update }: SectionProps) {
value={settings.theme?.accent || ''}
onChange={(e) => update('theme.accent', e.target.value)}
placeholder={theme.accent}
aria-invalid={fieldErrors?.['theme.accent'] ? true : undefined}
aria-describedby={fieldErrors?.['theme.accent'] ? 'theme-accent-error' : undefined}
/>
</div>
<FieldError id="theme-accent-error" message={fieldErrors?.['theme.accent']} />
</div>
</div>

View File

@@ -9,6 +9,20 @@ import { resolveTheme } from '@/lib/config/theme';
import type { SettingsYaml } from '@/lib/config/schema';
import type { Settings } from './types';
/**
* The message under an input whose value the site would ignore (`fieldErrors`).
* Not a live region: it changes as you type, and announcing every keystroke
* would drown the field itself. The input points at it with aria-describedby.
*/
export function FieldError({ id, message }: { id: string; message?: string }) {
if (!message) return null;
return (
<p id={id} className="admin-field-hint admin-field-hint--error">
{message}
</p>
);
}
/**
* One labelled block of related switches.
*

View File

@@ -1,3 +1,5 @@
import type { EnvLocks } from '@/lib/admin/envLocks';
/**
* Shared shape of the settings form (#554). The sections edit one object
* that mirrors settings.yaml; SettingsEditor owns it and hands each section
@@ -76,6 +78,7 @@ export interface Settings {
proofing?: {
enabled?: boolean;
allowMailto?: boolean;
email?: string;
};
watermark?: {
enabled?: boolean;
@@ -92,4 +95,12 @@ export interface SectionProps {
update: (path: string, value: unknown) => void;
/** Set several paths in one state update. */
updateMany: (entries: Record<string, unknown>) => void;
/** Fields an environment variable overrides; rendered locked (#605). */
envLocks?: EnvLocks;
/**
* Dotted path → message, for values the site would ignore (the save route
* rejects them). Checked as the form changes, so the message stands next to
* the input before a save is tried.
*/
fieldErrors?: Record<string, string>;
}

View File

@@ -7,8 +7,8 @@ import type { DoctorLevel } from '@/lib/admin/doctor';
*/
export const DOCTOR_LEVEL_EVENT = 'folio:doctor-level';
/** The three tones `status-badge-btn` is styled for. */
export type BadgeTone = 'connected' | 'disconnected' | 'unknown';
/** The tones `status-badge-btn` is styled for. */
export type BadgeTone = 'connected' | 'warning' | 'disconnected' | 'unknown';
export interface SystemHealthInput {
statusLoading: boolean;
@@ -49,7 +49,9 @@ export function systemHealth({
if (setupIncomplete) return { tone: 'unknown', label: 'Setup Incomplete' };
if (immich === 'error') return { tone: 'disconnected', label: 'System Degraded' };
if (doctorLevel === 'error') return { tone: 'disconnected', label: 'Needs Attention' };
if (doctorLevel === 'warn') return { tone: 'unknown', label: 'Check Diagnostics' };
// Amber, like the warnings on the diagnostics page it points to. Grey read
// as "no answer", which is what `unknown` means.
if (doctorLevel === 'warn') return { tone: 'warning', label: 'Check Diagnostics' };
if (immich === 'ok') return { tone: 'connected', label: 'System OK' };
return { tone: 'unknown', label: 'Status Unknown' };
}

View File

@@ -108,6 +108,11 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
*/
const holdRef = useRef(false);
const [status, setStatus] = useState<DraftStatus>('none');
/** The value as last rendered, for `saved()` when edits arrived during the save. */
const valueRef = useRef(value);
useEffect(() => {
valueRef.current = value;
}, [value]);
// Mirror the edits while there are any, and drop the draft once there are
// none — a save or a discard. Nothing is written before `load()` has set a
@@ -142,12 +147,19 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
[key],
);
/** Call after a successful save, with the fingerprint of what was saved. */
/**
* Call after a successful save, with the fingerprint of what was saved.
* `stillDirty` when the editor was changed while the save was in flight: those
* edits stay a draft, now on top of the version just written. Clearing it
* would leave them unkept until the next keystroke, since the mirror effect
* above only runs when the value or the dirty flag changes.
*/
const saved = useCallback(
(base: string) => {
(base: string, stillDirty = false) => {
baseRef.current = base;
holdRef.current = false;
clearDraft(key);
if (stillDirty) writeDraft(key, { base, value: valueRef.current });
else clearDraft(key);
setStatus('none');
},
[key],

View File

@@ -0,0 +1,20 @@
'use client';
import { useEffect, useRef } from 'react';
/**
* A ref that always holds the value of the latest render.
*
* For code that awaits a request and then has to know whether the editor moved
* on meanwhile: a save handler closes over the state it started with, so after
* the `await` it cannot tell "nothing changed" from "the operator kept typing".
* Comparing what was sent with `ref.current` can (every edit replaces the state
* value, so identity is enough).
*/
export function useLatest<T>(value: T) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}

View File

@@ -0,0 +1,97 @@
'use client';
/**
* Saving a content file without clobbering a change made elsewhere (#601).
*
* Each editor keeps the `version` its GET returned in a ref and saves through
* this hook, which sends it as `If-Match`. When the server answers 409 with
* `conflict: true` — the file was saved from another tab or device, edited by
* hand, or restored from a backup since it was loaded — the operator picks:
*
* - Reload: throw away this editor's edits and load the file as it is now.
* - Overwrite anyway: save again against the new version, replacing that change.
* - Keep editing (cancel): nothing happens; the edits and their draft stay.
*
* Other 409s (a rename onto a taken slug) carry no `conflict` flag and are
* returned like any other failure.
*/
import { useCallback, type MutableRefObject } from 'react';
import { useChoice } from './ConfirmDialog';
/** The If-Match header for a version, or none before the first load. */
export function ifMatch(version: string | null): Record<string, string> {
return version ? { 'If-Match': `"${version}"` } : {};
}
/** The body of any JSON answer; routes return objects. */
export type SaveData = Record<string, unknown> & { error?: string };
export type VersionedSaveResult =
| { kind: 'saved'; res: Response; data: SaveData }
| { kind: 'failed'; res: Response; data: SaveData | null }
| { kind: 'reload' }
| { kind: 'keep' };
export function isEditConflict(res: Response, data: unknown): data is { version: string } {
return (
res.status === 409 &&
!!data &&
typeof data === 'object' &&
(data as { conflict?: unknown }).conflict === true &&
typeof (data as { version?: unknown }).version === 'string'
);
}
type ConflictChoice = 'overwrite' | 'reload';
/**
* `what` names the file in the dialog ("The settings", "This entry").
* A network failure throws, as a plain fetch would.
*/
export function useVersionedSave() {
const choose = useChoice();
return useCallback(
async (
url: string,
body: unknown,
versionRef: MutableRefObject<string | null>,
what: string,
): Promise<VersionedSaveResult> => {
let version = versionRef.current;
for (;;) {
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...ifMatch(version) },
body: JSON.stringify(body),
});
const data = (await res.json().catch(() => null)) as SaveData | null;
if (isEditConflict(res, data)) {
const choice = await choose<ConflictChoice>({
title: 'Changed elsewhere',
message: `${what} was changed since you opened it — in another tab, on another device, by hand or by a backup restore. Saving now would replace that change.`,
actions: [
{ id: 'overwrite', label: 'Overwrite anyway' },
{ id: 'reload', label: 'Reload (discard mine)' },
],
cancelLabel: 'Keep editing',
});
if (choice === 'overwrite') {
version = data.version;
continue;
}
return { kind: choice === 'reload' ? 'reload' : 'keep' };
}
if (res.ok && data) {
if (typeof data.version === 'string') versionRef.current = data.version;
return { kind: 'saved', res, data };
}
return { kind: 'failed', res, data };
}
},
[choose],
);
}

View File

@@ -16,9 +16,15 @@ export const metadata: Metadata = {
*/
export const dynamic = 'force-dynamic';
/**
* `lang="en"`: `<html lang>` follows the site language (settings.yaml `lang`),
* but the admin is deliberately untranslated. Without this a screen reader
* read the English interface with a German voice on a `lang: de` site.
* The Listbox popup is portalled to <body> and still inherits the root value.
*/
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div className="admin-layout">
<div className="admin-layout" lang="en">
<AdminShell>{children}</AdminShell>
</div>
);

View File

@@ -0,0 +1,11 @@
import { PageContentEditor } from '../../../components/page-builder/PageContentEditor';
/** The block editor for one content page (#722), reached from Pages. */
export default async function AdminPageContentPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <PageContentEditor slug={slug} />;
}

View File

@@ -7,7 +7,7 @@ export default function AdminPagesPage() {
<PageHeader
kicker="Content"
title="Pages"
description="The home page hero, standalone albums and every subpage. Drag to reorder; changes go live when saved."
description="The home page hero, standalone albums, subpages and content pages. Drag to reorder; changes go live when saved."
/>
<PageBuilder />
</>

View File

@@ -0,0 +1,5 @@
import ProofingView from '../components/ProofingView';
export default function AdminProofingPage() {
return <ProofingView />;
}

View File

@@ -74,16 +74,32 @@
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.6rem 0.75rem;
border: none;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.picker-item:hover:not(.used) {
.picker-item:hover:not(.used),
.picker-item:focus-visible {
background: var(--admin-bg-elevated);
}
.picker-item:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: -2px;
}
.picker-item:focus-visible .picker-select-badge {
opacity: 1;
}
.picker-item.used {
opacity: 0.5;
cursor: not-allowed;

View File

@@ -106,25 +106,32 @@
align-items: flex-end;
background: var(--admin-bg-elevated);
border-radius: 6px;
overflow: hidden;
}
/* --admin-primary never existed, so every bar fell back to a fixed blue;
the accent is what the top-pages bars below use. */
.bar-fill {
width: 100%;
background: var(--admin-primary, #3b82f6);
border-radius: 6px 6px 0 0;
background: var(--admin-accent);
border-radius: 6px;
transition: height 0.3s ease;
position: relative;
display: flex;
align-items: flex-start;
justify-content: center;
}
/* Above the bar, not inside it: inside, a short bar cut its number in half
and an empty day had nowhere to put its 0. The chart's padding-top leaves
room above the tallest bar. */
.bar-val {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 3px;
font-size: 0.65rem;
font-weight: 600;
color: #fff;
margin-top: 4px;
line-height: 1;
color: var(--admin-text-secondary);
white-space: nowrap;
}
.bar-label {

View File

@@ -20,15 +20,16 @@
padding: 0.75rem;
}
/* A warning, so --admin-warning: the accent is red under studio-modern. */
.order-editor-notice {
margin: 0 0 0.75rem;
padding: 0.5rem 0.7rem;
border-radius: 6px;
font-size: 0.78rem;
line-height: 1.4;
color: var(--admin-accent);
border: 1px solid rgba(245, 158, 11, 0.3);
background: rgba(245, 158, 11, 0.05);
color: var(--admin-warning);
border: 1px solid color-mix(in srgb, var(--admin-warning) 30%, transparent);
background: color-mix(in srgb, var(--admin-warning) 5%, transparent);
}
.order-editor-section + .order-editor-section {
@@ -91,15 +92,27 @@
cursor: grabbing;
}
/* A button, so the tail can be pinned from the keyboard. */
.order-tile-unpinned {
display: block;
width: 100%;
padding: 0;
font: inherit;
color: inherit;
opacity: 0.55;
}
.order-tile-unpinned:hover {
.order-tile-unpinned:hover,
.order-tile-unpinned:focus-visible {
opacity: 1;
border-color: var(--admin-border);
}
.order-tile:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 2px;
}
.order-tile-index {
position: absolute;
bottom: 4px;
@@ -110,7 +123,7 @@
font-size: 0.68rem;
font-weight: 700;
text-align: center;
color: #000;
color: var(--admin-on-accent);
background: var(--admin-accent);
}

View File

@@ -113,7 +113,7 @@
height: 22px;
border-radius: 50%;
background: var(--admin-accent);
color: #000;
color: var(--admin-on-accent);
font-size: 0.7rem;
display: flex;
align-items: center;
@@ -167,3 +167,85 @@
margin-top: 0.3rem;
display: block;
}
/* ── Search and multi-select (#602) ────────────────────────── */
.asset-picker-search {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--admin-border);
color: var(--admin-text-muted);
}
.asset-picker-search-input {
flex: 1;
padding: 0.45rem 0.7rem;
background: var(--admin-bg-input);
border: 1px solid var(--admin-border);
border-radius: 6px;
color: var(--admin-text);
font-size: 0.85rem;
}
.asset-picker-search-input:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 1px;
border-color: var(--admin-border-focus);
}
/* Tiles are buttons now; reset what the browser gives a button. */
button.asset-picker-tile {
display: block;
width: 100%;
padding: 0;
font: inherit;
color: inherit;
}
.asset-picker-tile:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 2px;
}
.asset-picker-tile:disabled:not(.used) {
opacity: 0.55;
cursor: not-allowed;
}
.asset-picker-tile.selected {
border-color: var(--admin-accent);
box-shadow: 0 0 0 2px var(--admin-accent);
}
.asset-picker-order-badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 11px;
background: var(--admin-accent);
color: var(--admin-on-accent);
font-size: 0.72rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.asset-picker-selection {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
border-top: 1px solid var(--admin-border);
font-size: 0.85rem;
color: var(--admin-text-secondary);
}
.asset-picker-selection > span {
flex: 1;
}

View File

@@ -90,7 +90,7 @@ h3 .svg-icon {
.album-tile-btn:hover {
background: var(--admin-accent);
color: #000;
color: var(--admin-on-accent);
border-color: var(--admin-accent);
}

View File

@@ -12,7 +12,7 @@
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: var(--admin-accent);
color: #fff;
color: var(--admin-on-accent);
font-size: 0.7rem;
font-weight: 600;
}

View File

@@ -0,0 +1,92 @@
/* The client proofing view (Admin → Proofing). Builds on the analytics panels. */
.proofing-create-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0 1rem;
}
.proofing-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.proofing-card-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.proofing-card-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.proofing-card-body {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.proofing-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 0.1rem 0.5rem;
border-radius: 999px;
border: 1px solid var(--admin-border);
color: var(--admin-text-secondary);
}
.proofing-badge--submitted {
border-color: var(--admin-success);
color: var(--admin-success);
}
.proofing-badge--expired {
border-color: var(--admin-warning);
color: var(--admin-warning);
}
.proofing-card--expired {
opacity: 0.75;
}
.proofing-picks {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.75rem;
}
.proofing-pick {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.proofing-pick img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 4px;
background: var(--admin-bg-input);
}
.proofing-pick-name {
font-size: 0.7rem;
color: var(--admin-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -183,7 +183,7 @@
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: var(--admin-accent);
color: #fff;
color: var(--admin-on-accent);
font-family: var(--admin-font-label);
font-size: 0.68rem;
text-align: center;
@@ -214,17 +214,34 @@
justify-content: flex-start;
}
/* In the sidebar foot the dropdown opens upwards and to the right. */
/* In the sidebar foot the dropdown opens upwards, as wide as the foot. The
sidebar scrolls (overflow-y: auto), which clips sideways too: a fixed 280px
panel lost its values past the 232px column. */
.admin-sidebar .status-dropdown {
top: auto;
bottom: 100%;
left: 0;
right: auto;
right: 0;
margin: 0 0 0.5rem;
width: 280px;
width: auto;
border-radius: var(--admin-radius);
}
.admin-sidebar .status-item {
gap: 0.75rem;
}
.admin-sidebar .status-val {
text-align: right;
}
/* The collapsed sidebar does not clip (overflow: visible below), so the panel
keeps its width and opens out of the 64px column. */
.admin-app.is-collapsed .admin-sidebar .status-dropdown {
right: auto;
width: 280px;
}
.admin-sidebar-actions {
display: flex;
flex-direction: column;
@@ -656,6 +673,7 @@
.confirm-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.4rem;
@@ -836,6 +854,82 @@
text-transform: uppercase;
}
/* Content pages in the structure list (#722): a document icon and a PAGE tag
tell them apart from subpages at a glance. */
.pb-row-tag {
padding: 0 0.35rem;
border-radius: 2px;
background: var(--admin-bg-elevated);
font-family: var(--admin-font-label);
font-size: 0.58rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--admin-text-secondary);
}
.pb-offmenu {
min-height: 2.25rem;
border-radius: var(--admin-radius);
}
.pb-offmenu.is-over {
outline: 1px dashed var(--admin-border-hover);
}
.pb-offmenu .empty-hint {
margin: 0;
padding: 0.45rem 0.6rem;
font-size: 0.75rem;
}
.page-panel {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 720px;
}
.page-panel .builder-section-header {
margin-bottom: 0;
}
.page-panel textarea {
width: 100%;
resize: vertical;
}
.page-panel-switches {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.page-panel-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.new-page-card {
width: min(440px, 100%);
}
.new-page-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pb-add-row {
display: flex;
gap: 0.35rem;
}
.pb-add-row .pb-add {
flex: 1;
}
.pb-add {
display: flex;
align-items: center;
@@ -868,6 +962,11 @@
.subpage-drawer-container--inline {
max-width: none;
max-height: none;
/* The overlay sheet is 100dvh tall on a phone (subpage-sheet.css). In place,
that height clipped everything past the first screen — album list, grid
settings, Delete — and the footer slid over the password field. The
sheet grows with its content and the page scrolls. */
height: auto;
border-radius: var(--admin-radius);
box-shadow: none;
animation: none;
@@ -891,6 +990,11 @@
position: static;
max-height: none;
}
/* Picking a row scrolls the stacked panel up (PageBuilder revealPanel). */
.pb-panel {
scroll-margin-top: 1rem;
}
}
/* A custom album title is marked by weight, not by the accent: red italics
@@ -1324,3 +1428,34 @@
min-height: 44px;
}
}
/* ── Env-locked fields (#605) ──────────────────────────────── */
/* A field an environment variable overrides: the input is disabled and this
note names the variable. Neutral pill, not the warning or accent colour —
the lock is a fact about the deployment, not a problem. */
.admin-env-lock {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
margin-top: 0.4rem;
font-size: 0.75rem;
line-height: 1.4;
color: var(--admin-text-muted);
}
.admin-env-lock-pill {
display: inline-flex;
align-items: center;
gap: 5px;
height: 20px;
padding: 0 8px;
border: 1px solid var(--admin-border-hover);
border-radius: 999px;
font-family: var(--admin-font-label);
font-size: 0.62rem;
letter-spacing: 0.1em;
text-transform: uppercase;
flex-shrink: 0;
}

View File

@@ -27,8 +27,10 @@
animation: save-bar-in 0.2s ease-out;
}
/* Unsaved changes are a warning, never the accent: studio-modern makes the
accent red, and the bar read as an error. */
.floating-save-bar.dirty {
border-color: var(--admin-accent);
border-color: var(--admin-warning);
}
/* Keyframes must carry the translateX(-50%) or the bar jumps horizontally. */
@@ -47,7 +49,7 @@
width: 8px;
height: 8px;
border-radius: 50%;
background: #ff9800;
background: var(--admin-warning);
display: inline-block;
margin-right: 6px;
animation: pulse 1.5s infinite;
@@ -56,15 +58,15 @@
@keyframes pulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.7);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--admin-warning) 70%, transparent);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 8px rgba(255, 152, 0, 0);
box-shadow: 0 0 0 8px transparent;
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(255, 152, 0, 0);
box-shadow: 0 0 0 0 transparent;
}
}

View File

@@ -269,6 +269,11 @@
animation: pulse-red 1.5s infinite;
}
/* The doctor found something worth a look, nothing broken: amber, still. */
.status-badge-btn.warning .status-dot {
background-color: var(--admin-warning);
}
/* The diagnostics request itself did not run — deliberately not red, and not
animated: an unanswered check is not a fault report. */
.status-badge-btn.unknown .status-dot {
@@ -640,6 +645,11 @@
color: var(--admin-error);
}
/* A consequence to know about, not an error: --admin-accent may be red (#722). */
.admin-field-hint--warn {
color: var(--admin-warning);
}
/* A link out of a section header, e.g. to the page the section configures.
It shows the saved state, not unsaved edits. */
.settings-section-action {

View File

@@ -98,7 +98,7 @@
display: inline-flex;
align-items: center;
font-size: 0.75rem;
color: var(--admin-accent);
color: var(--admin-warning);
font-weight: 500;
}

View File

@@ -37,7 +37,7 @@
.hover-action-btn {
font-size: 0.78rem;
font-weight: 600;
color: #ffffff;
color: var(--admin-on-accent);
background: var(--admin-accent, #e60012);
padding: 0.4rem 0.85rem;
border-radius: 20px;

View File

@@ -18,8 +18,13 @@
--admin-border-focus: #777777;
--admin-text: #f0f0f0;
--admin-text-secondary: #a0a0a0;
--admin-text-muted: #707070;
--admin-accent: #f59e0b;
/* ≥4.6:1 on every admin surface (#0c0c0c–#222222); #707070 fell to 3.2:1. */
--admin-text-muted: #8a8a8a;
/* Not amber: that is --admin-warning, and with both the same every active
marker and focus ring read as a warning. 7.7:1 on the background. */
--admin-accent: #60a5fa;
/* Text on an accent fill (counts, badges). */
--admin-on-accent: #000000;
/* Focus used to show only as a border colour change from #2e2e2e to
#666666, a contrast of 2.37:1 where WCAG 1.4.11 asks for 3:1 against
adjacent colours, so keyboard users could barely see where they were.
@@ -53,8 +58,10 @@
--admin-border-focus: #7f7f7f;
--admin-text: #1a1a1a;
--admin-text-secondary: #555555;
--admin-text-muted: #888888;
--admin-accent: #d97706;
/* ≥4.6:1 on white and on #efefee; #888888 was 3.1–3.5:1. */
--admin-text-muted: #6b6b6b;
--admin-accent: #2563eb;
--admin-on-accent: #ffffff;
--admin-success: #16a34a;
--admin-warning: #b45309;
--admin-error: #dc2626;
@@ -66,6 +73,20 @@
--admin-overlay: rgba(0, 0, 0, 0.4);
}
/* studio-modern restyles the admin in its preset file with a red accent and
warm greys. Two of its values are corrected here, one step more specific
(html) so the order the sheets load in does not matter: its muted text
(#6b6b66 dark, #8a8a84 light) sat at 3.0–3.6:1, and text on its red reads
better white (4.8:1) than black (4.4:1). */
html[data-preset='studio-modern'] .admin-layout {
--admin-text-muted: #8c8c86;
--admin-on-accent: #ffffff;
}
html[data-preset='studio-modern'][data-theme='light'] .admin-layout {
--admin-text-muted: #6b6b66;
}
.admin-layout {
min-height: 100vh;
background: var(--admin-bg);