release: v0.16.0

See CHANGELOG.md for the full v0.16.0 entry.

---------

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>
This commit is contained in:
Ralksta
2026-09-22 14:12:13 +02:00
committed by GitHub
parent c3f92ca4ec
commit a3a6319c29
120 changed files with 6818 additions and 975 deletions

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, type ReactNode } from 'react';
import AdminLogin from './components/AdminLogin';
import AdminDashboard from './components/AdminDashboard';
import { SESSION_EXPIRED_EVENT } from './components/sessionExpiry';
import './admin.css';
/**
@@ -13,6 +14,13 @@ import './admin.css';
export default function AdminShell({ children }: { children: ReactNode }) {
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [enabled, setEnabled] = useState(true);
// Sessions last 24 hours (lib/admin/auth.ts), but nothing re-checked
// authentication after the mount-time check below — leave a tab open
// overnight and every admin request answered 401 while the UI still looked
// signed in (#596). SESSION_EXPIRED_EVENT is raised by the fetch call sites
// that noticed; this is what turns that into dropping back to the login
// screen with an explanation instead of each of them failing quietly.
const [sessionExpired, setSessionExpired] = useState(false);
useEffect(() => {
async function checkAuth() {
@@ -28,6 +36,15 @@ export default function AdminShell({ children }: { children: ReactNode }) {
checkAuth();
}, []);
useEffect(() => {
function onSessionExpired() {
setAuthenticated(false);
setSessionExpired(true);
}
window.addEventListener(SESSION_EXPIRED_EVENT, onSessionExpired);
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, onSessionExpired);
}, []);
if (authenticated === null) {
return (
<div className="admin-loading">
@@ -51,7 +68,15 @@ export default function AdminShell({ children }: { children: ReactNode }) {
}
if (!authenticated) {
return <AdminLogin onSuccess={() => setAuthenticated(true)} />;
return (
<AdminLogin
notice={sessionExpired ? 'Your session expired. Sign in again.' : undefined}
onSuccess={() => {
setSessionExpired(false);
setAuthenticated(true);
}}
/>
);
}
return <AdminDashboard onLogout={() => setAuthenticated(false)}>{children}</AdminDashboard>;

View File

@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor, act } from '@testing-library/react';
import AdminShell from '../AdminShell';
import { SESSION_EXPIRED_EVENT } from '../components/sessionExpiry';
vi.mock('next/navigation', () => ({
usePathname: () => '/admin',
}));
afterEach(cleanup);
/**
* Sessions last 24 hours, but nothing re-checked authentication after the
* mount-time check below — leave a tab open overnight and every admin
* request answered 401 while the UI still looked signed in (#596).
* SESSION_EXPIRED_EVENT, raised by any admin fetch call site that notices a
* 401, is what this pins: it drops the whole panel back to the login screen
* with an explanation, instead of each call site failing quietly.
*/
describe('AdminShell session expiry', () => {
it('returns to the login screen with a notice when the session expires', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
if (url === '/api/admin/auth') {
return new Response(JSON.stringify({ authenticated: true, enabled: true }), {
status: 200,
});
}
// AdminDashboard's own status/doctor fetches — a failure there is
// already handled as "unknown", not the thing under test here.
return new Response(null, { status: 500 });
}),
);
render(
<AdminShell>
<div>Panel content</div>
</AdminShell>,
);
await waitFor(() => expect(screen.getByText('Panel content')).toBeTruthy());
expect(screen.queryByLabelText('Password')).toBeNull();
act(() => {
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT));
});
await waitFor(() =>
expect(screen.getByText('Your session expired. Sign in again.')).toBeTruthy(),
);
expect(screen.queryByText('Panel content')).toBeNull();
});
it('shows no notice on an ordinary first visit', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({ authenticated: false, enabled: true }))),
);
render(
<AdminShell>
<div>Panel content</div>
</AdminShell>,
);
await waitFor(() => expect(screen.getByLabelText('Password')).toBeTruthy());
expect(screen.queryByText('Your session expired. Sign in again.')).toBeNull();
});
});

View File

@@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import SaveBar from '../components/SaveBar';
/**
* The first component test in the project, and deliberately a small one: it is
* here to show the shape (`// @vitest-environment jsdom` on the first line,
* render, assert, cleanup) as much as to cover SaveBar.
*
* SaveBar earns it though. It decides whether an outcome reads as success or
* failure by testing whether the message happens to start with "Error" — #600
* replaces that with a typed state, and this pins the behaviour so the swap can
* be verified rather than eyeballed.
*/
afterEach(cleanup);
const props = {
dirty: false,
saving: false,
saveMessage: '',
onSave: () => {},
label: 'Save',
};
describe('SaveBar', () => {
it('stays out of the way when there is nothing to act on', () => {
const { container } = render(<SaveBar {...props} />);
// It is pinned over the page, so rendering it with nothing to say would
// cover content for no reason.
expect(container.firstChild).toBeNull();
});
it('announces unsaved changes', () => {
render(<SaveBar {...props} dirty />);
expect(screen.getByText('Unsaved changes')).toBeTruthy();
});
it('only offers saving when there is something to save', () => {
const { rerender } = render(<SaveBar {...props} saveMessage="Saved!" />);
expect(screen.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', true);
rerender(<SaveBar {...props} dirty />);
expect(screen.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', false);
});
it('blocks a second save while one is in flight', () => {
render(<SaveBar {...props} dirty saving />);
const button = screen.getByRole('button', { name: 'Saving...' });
expect(button).toHaveProperty('disabled', true);
});
it('calls onSave when clicked', () => {
const onSave = vi.fn();
render(<SaveBar {...props} dirty onSave={onSave} />);
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledOnce();
});
it('tells a failure from a success by the message prefix', () => {
// Pinned as it is, not as it should be: the prefix check is the thing #600
// replaces. A message that fails without starting with "Error" is styled as
// a success today, which is precisely the argument for a typed state.
const { rerender } = render(<SaveBar {...props} saveMessage="Error: Failed to save" />);
expect(screen.getByText('Error: Failed to save').className).toContain('error');
rerender(<SaveBar {...props} saveMessage="Saved!" />);
expect(screen.getByText('Saved!').className).toContain('success');
rerender(<SaveBar {...props} saveMessage="Could not reach the server" />);
expect(screen.getByText('Could not reach the server').className).toContain('success');
});
it('offers the preview link only where it belongs', () => {
const { rerender } = render(<SaveBar {...props} dirty />);
expect(screen.queryByRole('link')).toBeNull();
rerender(<SaveBar {...props} dirty showPreview />);
// `?fresh=1` is what bypasses the cache, so the preview shows the save.
expect(screen.getByRole('link')).toHaveProperty('href', expect.stringContaining('/?fresh=1'));
});
});

View File

@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import {
parseAlbumEntries,
serializeAlbumEntries,
hasAlbumOptions,
} from '../components/page-builder/albumEntries';
import type { AlbumEntryObject } from '@/lib/config/schema';
/**
* The page builder rewrites gallery.yaml wholesale on every save, so a field it
* does not know about is not merely ignored — it is deleted. `location` was
* lost this way (#614): an album deliberately placed at city level, or hidden
* from the map, silently went back to exact coordinates the next time anything
* unrelated was saved.
*
* The round-trip test below is the part that matters. It is driven by a sample
* entry carrying every key of AlbumEntryObject, so the next field added to the
* schema and forgotten here fails this suite instead of quietly disappearing
* from someone's configuration.
*/
const uuid = '11111111-1111-1111-1111-111111111111';
/**
* One value per key of AlbumEntryObject. The type annotation is the enforcement:
* adding a key to the schema without adding it here is a type error, and the
* round-trip test then proves the builder preserves it.
*/
const everyOption: Required<Omit<AlbumEntryObject, 'grid'>> & Pick<AlbumEntryObject, 'grid'> = {
title: 'Kloster Chorin',
description: 'Brick gothic in the Uckermark',
password: 'hunter2',
heroImage: '22222222-2222-2222-2222-222222222222',
sort: 'oldest',
assetOrder: ['33333333-3333-3333-3333-333333333333'],
coverPosition: '50% 25%',
download: true,
location: 'hidden',
grid: { columns: 4, gap: 8, aspectRatio: '3/2', layout: 'masonry' },
};
describe('album entry round trip', () => {
it('preserves every option a gallery.yaml entry can carry', () => {
const parsed = parseAlbumEntries([{ [uuid]: everyOption }]);
const [serialized] = serializeAlbumEntries(parsed);
expect(serialized).toEqual({ [uuid]: everyOption });
});
it('keeps map precision across a save', () => {
const parsed = parseAlbumEntries([{ [uuid]: { location: 'city' } }]);
expect(parsed[0].location).toBe('city');
expect(serializeAlbumEntries(parsed)).toEqual([{ [uuid]: { location: 'city' } }]);
});
it('does not collapse a location-only entry back to a bare id', () => {
// This is the actual mechanism of the bug: an entry that looks "empty" is
// written back as a plain UUID string, taking its options with it.
const entry = { id: uuid, location: 'hidden' };
expect(hasAlbumOptions(entry)).toBe(true);
expect(serializeAlbumEntries([entry])).toEqual([{ [uuid]: { location: 'hidden' } }]);
});
it('carries a value this version does not recognise', () => {
// Narrowing belongs to deriveGallery(), which rejects a typo loudly. The
// builder's job is to hand back what it was given rather than drop it.
const parsed = parseAlbumEntries([{ [uuid]: { location: 'district' } }]);
expect(serializeAlbumEntries(parsed)).toEqual([{ [uuid]: { location: 'district' } }]);
});
it('still collapses an entry that really is bare', () => {
expect(serializeAlbumEntries([{ id: uuid }])).toEqual([uuid]);
expect(serializeAlbumEntries([{ id: uuid, title: 'Just a title' }])).toEqual([
{ [uuid]: 'Just a title' },
]);
});
});

View File

@@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation';
import BackupManagerModal from './BackupManagerModal';
import type { DoctorLevel } from '@/lib/admin/doctor';
import { DOCTOR_LEVEL_EVENT, systemHealth } from './systemHealth';
import { reportIfSessionExpired } from './sessionExpiry';
import * as Icons from './Icons';
interface Props {
@@ -15,10 +16,11 @@ interface Props {
}
const TABS = [
{ label: 'Pages', href: '/admin/pages', match: /^\/admin(\/pages)?$/ },
{ label: 'Pages', href: '/admin/pages', match: /^\/admin\/pages$/ },
{ label: 'Journal', href: '/admin/journal', match: /^\/admin\/journal/ },
{ label: 'Settings', href: '/admin/settings', match: /^\/admin\/settings/ },
{ label: 'Analytics', href: '/admin/analytics', match: /^\/admin\/analytics/ },
{ label: 'Diagnostics', href: '/admin/diagnostics', match: /^\/admin\/diagnostics/ },
{ label: 'Help', href: '/admin/help', match: /^\/admin\/help/ },
];
@@ -47,7 +49,15 @@ export default function AdminDashboard({ onLogout, children }: Props) {
async function handleReload() {
setSaving(true);
try {
await fetch('/api/admin/reload', { method: 'POST' });
const res = await fetch('/api/admin/reload', { method: 'POST' });
// A 401 or a 500 used to be indistinguishable from success here — the
// result was never checked at all (#596).
if (!res.ok) {
if (!reportIfSessionExpired(res)) {
alert(`Reload failed (HTTP ${res.status}).`);
}
return;
}
// Refresh status after reload
await fetchStatus();
} finally {
@@ -141,15 +151,19 @@ export default function AdminDashboard({ onLogout, children }: Props) {
<div className="admin-header-left">
<h1>Immich Folio</h1>
<nav className="admin-tabs">
{TABS.map((t) => (
<Link
key={t.href}
href={t.href}
className={`admin-tab ${t.match.test(pathname) ? 'active' : ''}`}
>
{t.label}
</Link>
))}
{TABS.map((t) => {
const active = t.match.test(pathname);
return (
<Link
key={t.href}
href={t.href}
className={`admin-tab ${active ? 'active' : ''}`}
aria-current={active ? 'page' : undefined}
>
{t.label}
</Link>
);
})}
</nav>
</div>
<div className="admin-header-right">
@@ -247,24 +261,6 @@ export default function AdminDashboard({ onLogout, children }: Props) {
)}
</div>
</div>
<div className="status-dropdown-footer">
<Link
href="/admin/diagnostics"
className="admin-btn admin-btn-sm"
onClick={() => setShowStatus(false)}
>
<Icons.IconShieldCheck size={14} /> Diagnostics
</Link>
<button
className="admin-btn admin-btn-sm"
onClick={() => {
setShowStatus(false);
setShowBackupModal(true);
}}
>
<Icons.IconArchive size={14} /> Manage Backups
</button>
</div>
</div>
</>
)}

View File

@@ -4,9 +4,11 @@ import { useState } from 'react';
interface Props {
onSuccess: () => void;
/** Shown above the form, e.g. "Your session expired. Sign in again." (#596) */
notice?: string;
}
export default function AdminLogin({ onSuccess }: Props) {
export default function AdminLogin({ onSuccess, notice }: Props) {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
@@ -44,6 +46,8 @@ export default function AdminLogin({ onSuccess }: Props) {
<p>Admin Panel</p>
</div>
{notice && <div className="admin-notice">{notice}</div>}
<div className="admin-field">
<label htmlFor="admin-password">Password</label>
<input

View File

@@ -1,6 +1,5 @@
'use client';
import Link from 'next/link';
import * as Icons from './Icons';
import { en } from '@/lib/i18n/locales/en';
import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts';
@@ -70,17 +69,11 @@ export default function HelpView() {
</span>
<span className="settings-group-desc">
Checks the things that fail silently — a wrong proxy hop count, a deleted album ID, a
plaintext password, an album Immich does not consider shared. It also sits behind the
status badge in the top bar, which is where it belongs when the question is whether
anything needs attention right now.
plaintext password, an album Immich does not consider shared. Open it from the{' '}
<strong>Diagnostics</strong> tab in the top bar; the status badge next to it still gives
a quick read on whether anything needs attention right now.
</span>
</div>
<div className="help-action">
<Link href="/admin/diagnostics" className="admin-btn">
<Icons.IconShieldCheck size={14} /> Open diagnostics
</Link>
</div>
</div>
</div>
);

View File

@@ -26,6 +26,7 @@ import {
import AssetPicker from './AssetPicker';
import { BlockBadge } from './BlockBadge';
import { useUnsavedGuard } from './useUnsavedGuard';
import { reportIfSessionExpired } from './sessionExpiry';
import { EssayView } from '@/app/[...path]/EssayView';
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
import './journal-studio.css';
@@ -435,23 +436,33 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
title: string;
} | null>(null);
/** Set when the entry could not be fetched; blocks saving over it. */
const [loadError, setLoadError] = useState<string | null>(null);
// Load entry
useEffect(() => {
async function load() {
setLoading(true);
setLoadError(null);
try {
const res = await fetch(`/api/admin/journal/${slug}`);
if (res.ok) {
const data = await res.json();
const md = data.entry.rawMarkdown;
setRawMarkdown(md);
setParsed(parseJournalMarkdown(md));
setDirty(false);
} else {
alert('Failed to load journal entry');
if (!res.ok) {
throw new Error(
res.status === 401
? 'Your session has expired. Sign in again to continue.'
: `The server answered ${res.status}.`,
);
}
} catch {
alert('Error loading journal entry');
const data = await res.json();
const md = data.entry.rawMarkdown;
setRawMarkdown(md);
setParsed(parseJournalMarkdown(md));
setDirty(false);
} catch (err) {
console.error('Failed to load journal entry:', err);
// An empty editor saved over the entry replaces it with nothing, so
// this blocks the save rather than only reporting it.
setLoadError(err instanceof Error ? err.message : 'The entry could not be loaded.');
} finally {
setLoading(false);
}
@@ -504,6 +515,9 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
// Save
const handleSave = async () => {
// The editor is not rendered in this state, but Cmd+S still reaches here.
if (loadError) return;
setSaving(true);
try {
const res = await fetch(`/api/admin/journal/${slug}`, {
@@ -516,7 +530,7 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
if (res.ok) {
setDirty(false);
} else {
} else if (!reportIfSessionExpired(res)) {
const data = await res.json();
alert(data.error || 'Failed to save');
}
@@ -636,6 +650,25 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
);
}
/**
* Replaces the editor rather than sitting above it: an empty editor saved
* over the entry would replace the text with nothing.
*/
if (loadError) {
return (
<div className="admin-error" role="alert">
<strong>This 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.
</p>
<button className="admin-btn admin-btn-secondary" onClick={onBack}>
Back to entries
</button>
</div>
);
}
return (
<div className="journal-editor-container">
{/* Top Bar */}

View File

@@ -27,6 +27,7 @@ import AlbumDrawer from './page-builder/AlbumDrawer';
import { SortableAlbumCard } from './page-builder/AlbumCard';
import SubpageDrawer from './page-builder/SubpageDrawer';
import { findAlbumAddress } from './page-builder/findAlbumAddress';
import { parseAlbumEntries, serializeAlbumEntries } from './page-builder/albumEntries';
import {
seedCoverGrid,
type ActiveEditAlbumAddress,
@@ -40,8 +41,7 @@ import {
} from './page-builder/types';
import { useScrollLock } from './useScrollLock';
import { useUnsavedGuard } from './useUnsavedGuard';
import { DEFAULT_ALBUM_SORT, isAlbumSortMode } from '@/lib/albumSort';
import { type AlbumEntryObject } from '@/lib/config/schema';
import { reportIfSessionExpired } from './sessionExpiry';
import {
IconCamera,
IconFolder,
@@ -62,73 +62,6 @@ interface GalleryState {
subpages: Subpage[];
}
// ── Helpers ────────────────────────────────────────────────────
type RawAlbumEntry = string | Record<string, string | AlbumEntryObject>;
/**
* Whether an entry carries anything beyond its ID.
*
* Both collapse rules below depend on this, and they are the reason every new
* per-album option has to be listed here: an entry that looks "empty" is
* serialized back to a bare UUID string, so a field missing from this check is
* silently dropped on the next save.
*/
function hasAlbumOptions(entry: AlbumEntry): boolean {
return Boolean(
entry.description ||
entry.password ||
entry.heroImage ||
(entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) ||
entry.assetOrder?.length ||
entry.grid ||
entry.coverPosition,
);
}
function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[] {
if (!raw) return [];
return raw.map((entry) => {
if (typeof entry === 'string') return { id: entry };
const [id, value] = Object.entries(entry)[0];
if (typeof value === 'string') return { id, title: value };
return {
id,
title: value.title,
description: value.description,
password: value.password,
heroImage: value.heroImage,
sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
assetOrder: value.assetOrder,
grid: value.grid,
coverPosition: value.coverPosition,
};
});
}
function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
return entries.map((entry) => {
const extras = hasAlbumOptions(entry);
if (!entry.title && !extras) return entry.id;
if (entry.title && !extras) return { [entry.id]: entry.title };
const val: AlbumEntryObject = {};
// Only when set: a sort-only entry would otherwise be written with an empty
// title, which deriveGallery ignores but which still lands in the YAML.
if (entry.title) val.title = entry.title;
if (entry.description) val.description = entry.description;
if (entry.password) val.password = entry.password;
if (entry.heroImage) val.heroImage = entry.heroImage;
if (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) val.sort = entry.sort;
// Persisted regardless of the mode, so manual → newest → manual does not
// throw away a hand-curated order.
if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder;
if (entry.grid) val.grid = entry.grid;
if (entry.coverPosition) val.coverPosition = entry.coverPosition;
return { [entry.id]: val };
});
}
// ── Sortable Hero Tile ─────────────────────────────────────────
function SortableHeroTile({
@@ -269,6 +202,12 @@ export default function PageBuilder() {
const [gallery, setGallery] = useState<GalleryState>({ hero: [], albums: [], subpages: [] });
const [immichAlbums, setImmichAlbums] = useState<ImmichAlbumInfo[]>([]);
const [loading, setLoading] = useState(true);
/**
* Set when gallery.yaml could not be fetched. It blocks saving: the builder
* writes the whole file from its own state, so saving an empty builder
* publishes an empty gallery.
*/
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
@@ -356,25 +295,35 @@ export default function PageBuilder() {
async function loadData() {
setLoading(true);
setLoadError(null);
try {
const [galleryRes, albumsRes] = await Promise.all([
fetch('/api/admin/gallery'),
fetch('/api/admin/albums'),
]);
if (galleryRes.ok) {
const { gallery: raw } = await galleryRes.json();
const parsed = parseGalleryYaml(raw);
setGallery(parsed);
openAlbumFromLink(parsed);
if (!galleryRes.ok) {
throw new Error(
galleryRes.status === 401
? 'Your session has expired. Sign in again to continue.'
: `The server answered ${galleryRes.status}.`,
);
}
const { gallery: raw } = await galleryRes.json();
const parsed = parseGalleryYaml(raw);
setGallery(parsed);
openAlbumFromLink(parsed);
// A failed album list is survivable — it only empties the picker, and
// saving with it empty changes nothing in gallery.yaml.
if (albumsRes.ok) {
const { albums } = await albumsRes.json();
setImmichAlbums(albums);
}
} catch (err) {
console.error('Failed to load admin data:', err);
setLoadError(err instanceof Error ? err.message : 'The page structure could not be loaded.');
} finally {
setLoading(false);
}
@@ -424,6 +373,7 @@ export default function PageBuilder() {
password: sp.password as string | undefined,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
location: sp.location as string | undefined,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
albums: parseAlbumEntries(sp.albums as Array<string | Record<string, string>> | undefined),
@@ -451,6 +401,7 @@ export default function PageBuilder() {
password: sp.password as string | undefined,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
location: sp.location as string | undefined,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
albums: parseAlbumEntries(
@@ -474,6 +425,9 @@ export default function PageBuilder() {
// ── Save ──────────────────────────────────────────────────────
async function handleSave() {
// Not reachable from the UI in this state, but Cmd+S still gets here.
if (loadError) return;
setSaving(true);
setSaveMessage('');
@@ -497,6 +451,7 @@ export default function PageBuilder() {
if (sp.essayFile) entry.essayFile = sp.essayFile;
if (sp.grid) entry.grid = sp.grid;
if (sp.coverGrid) entry.coverGrid = sp.coverGrid;
if (sp.location) entry.location = sp.location;
if (sp.sections && sp.sections.length > 0) {
entry.sections = sp.sections.map((sec) => {
@@ -530,7 +485,7 @@ export default function PageBuilder() {
setDirty(false);
setSaveMessage(data.message || 'Saved successfully!');
setTimeout(() => setSaveMessage(''), 5000);
} else {
} else if (!reportIfSessionExpired(res)) {
const err = await res.json();
setSaveMessage(`Error: ${err.error}`);
}
@@ -729,7 +684,12 @@ export default function PageBuilder() {
}
// ── Album Removal ────────────────────────────────────────────
// Confirmed like removeSubpage: an album entry carries its grid overrides,
// cover asset and manual assetOrder with it, there is no undo stack in the
// builder, and the only escape from an accidental click used to be
// reloading the page — which discards every other unsaved edit too (#597).
function removeStandaloneAlbum(index: number) {
if (!confirm('Remove this album from the gallery?')) return;
setGallery((g) => ({
...g,
albums: g.albums.filter((_, i) => i !== index),
@@ -738,6 +698,7 @@ export default function PageBuilder() {
}
function removeSubpageAlbum(subpageIndex: number, albumIndex: number) {
if (!confirm('Remove this album from the subpage?')) return;
setGallery((g) => {
const subpages = [...g.subpages];
const sp = { ...subpages[subpageIndex] };
@@ -749,6 +710,7 @@ export default function PageBuilder() {
}
function removeSectionAlbum(subpageIndex: number, sectionIndex: number, albumIndex: number) {
if (!confirm('Remove this album from the section?')) return;
setGallery((g) => {
const subpages = [...g.subpages];
const sp = { ...subpages[subpageIndex] };
@@ -765,6 +727,7 @@ export default function PageBuilder() {
// ── Hero Management ──────────────────────────────────────────
function removeHero(index: number) {
if (!confirm('Remove this photo from the home page hero?')) return;
setGallery((g) => ({
...g,
hero: g.hero.filter((_, i) => i !== index),
@@ -813,6 +776,26 @@ export default function PageBuilder() {
);
}
/**
* Replaces the builder rather than sitting above it. An empty builder looks
* exactly like a site with no pages yet, and saving it used to publish an
* empty gallery.yaml over a working one.
*/
if (loadError) {
return (
<div className="admin-error" role="alert">
<strong>The page structure could not be loaded.</strong> {loadError}
<p>
Nothing has been changed. Saving stays disabled until it loads, so an empty builder cannot
overwrite your gallery.
</p>
<button className="admin-btn admin-btn-secondary" onClick={loadData}>
Try again
</button>
</div>
);
}
// Filter standalone albums
const filteredAlbums = gallery.albums.filter((album) => {
if (!searchQuery) return true;

View File

@@ -6,6 +6,7 @@ import Link from 'next/link';
import * as Icons from './Icons';
import SaveBar from './SaveBar';
import { useUnsavedGuard } from './useUnsavedGuard';
import { reportIfSessionExpired } from './sessionExpiry';
import ToggleCard from './fields/ToggleCard';
import OptionGrid, { toOptions } from './fields/OptionGrid';
// Direct import from the theme module, not from '@/lib/config': the config
@@ -587,6 +588,12 @@ export default function SettingsEditor() {
source: 'env' | 'settings' | 'none';
} | null>(null);
const [loading, setLoading] = useState(true);
/**
* Set when the settings could not be fetched. It blocks saving, because an
* empty form saved over a live settings.yaml replaces it with whatever one
* field the user happened to touch.
*/
const [loadError, setLoadError] = useState<string | null>(null);
// Collapsed by default: the four metadata switches are a detail of one
// decision, and showing them permanently is what made the section a wall (#510).
const [metadataOpen, setMetadataOpen] = useState(false);
@@ -671,15 +678,28 @@ export default function SettingsEditor() {
async function loadSettings() {
setLoading(true);
setLoadError(null);
try {
const res = await fetch('/api/admin/settings');
if (res.ok) {
const { settings: data, siteUrl } = await res.json();
setSettings(data || {});
setSiteUrlInfo(siteUrl ?? null);
if (!res.ok) {
// 401 is the common one: the session lasts 24h and nothing re-checks
// it, so a tab left open overnight lands here. reportIfSessionExpired
// also drops the whole panel back to the login screen (#596); the
// thrown message below still covers the brief moment before that
// re-render happens, and any other non-ok status.
reportIfSessionExpired(res);
throw new Error(
res.status === 401
? 'Your session has expired. Sign in again to continue.'
: `The server answered ${res.status}.`,
);
}
const { settings: data, siteUrl } = await res.json();
setSettings(data || {});
setSiteUrlInfo(siteUrl ?? null);
} catch (err) {
console.error('Failed to load settings:', err);
setLoadError(err instanceof Error ? err.message : 'The settings could not be loaded.');
} finally {
setLoading(false);
}
@@ -726,6 +746,9 @@ export default function SettingsEditor() {
}
async function handleSave() {
// The form is not rendered in this state, but Cmd+S still reaches here.
if (loadError) return;
setSaving(true);
setSaveMessage('');
@@ -750,9 +773,15 @@ export default function SettingsEditor() {
setSaveMessage(data.message || 'Saved!');
router.refresh();
setTimeout(() => setSaveMessage(''), 5000);
} else {
} else if (!reportIfSessionExpired(res)) {
const err = await res.json();
setSaveMessage(`Error: ${err.error}`);
// 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.
const fields: string[] = Array.isArray(err.fields)
? err.fields.map((f: { field?: string }) => f.field || 'settings')
: [];
setSaveMessage(`Error: ${err.error}${fields.length ? ` — ${fields.join(', ')}` : ''}`);
}
} catch {
setSaveMessage('Error: Failed to save');
@@ -763,23 +792,36 @@ export default function SettingsEditor() {
async function loadAboutContent() {
setAboutLoading(true);
setAboutMessage('');
try {
const res = await fetch('/api/admin/about');
if (res.ok) {
const data = await res.json();
setAboutMeta(data.meta || {});
setAboutBody(data.body || '');
setAboutGearText(data.meta?.gear?.join('\n') || '');
setAboutLoaded(true);
if (!res.ok) {
throw new Error(
res.status === 401
? 'Your session has expired. Sign in again to continue.'
: `The server answered ${res.status}.`,
);
}
const data = await res.json();
setAboutMeta(data.meta || {});
setAboutBody(data.body || '');
setAboutGearText(data.meta?.gear?.join('\n') || '');
setAboutLoaded(true);
} catch (err) {
console.error('Failed to load about content:', err);
// aboutLoaded stays false, which is what blocks the save below: an empty
// editor written to about.md would replace the page with nothing.
setAboutMessage(
`Error: ${err instanceof Error ? err.message : 'The About page could not be loaded.'}`,
);
} finally {
setAboutLoading(false);
}
}
async function saveAboutContent() {
if (!aboutLoaded) return;
setAboutSaving(true);
setAboutMessage('');
const cleanedMeta = { ...aboutMeta };
@@ -842,6 +884,26 @@ export default function SettingsEditor() {
);
}
/**
* Deliberately replaces the form rather than sitting above it. An empty form
* is indistinguishable from a site with no settings yet, and editing one
* field in it used to publish that field as the entire settings.yaml.
*/
if (loadError) {
return (
<div className="admin-error" role="alert">
<strong>Settings could not be loaded.</strong> {loadError}
<p>
Nothing has been changed. Saving stays disabled until they load, so an empty form cannot
overwrite your configuration.
</p>
<button className="admin-btn admin-btn-secondary" onClick={loadSettings}>
Try again
</button>
</div>
);
}
// Resolved the same way the site resolves it, so the switches show what a
// visitor actually sees — including a config that only ever set the older
// `exifOnHover`.
@@ -898,6 +960,7 @@ export default function SettingsEditor() {
key={sec.id}
href={`/admin/settings/${sec.id}`}
className={`settings-nav-item ${activeSection === sec.id ? 'active' : ''}`}
aria-current={activeSection === sec.id ? 'page' : undefined}
>
{sec.label}
</Link>

View File

@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import AdminDashboard from '../AdminDashboard';
import { SESSION_EXPIRED_EVENT } from '../sessionExpiry';
vi.mock('next/navigation', () => ({
usePathname: () => '/admin',
}));
afterEach(cleanup);
/**
* `handleReload` used to await POST /api/admin/reload without checking
* `res.ok`, so a 401 or a 500 was indistinguishable from success (#596).
*/
describe('AdminDashboard reload failure reporting', () => {
it('reports a non-ok, non-401 reload failure', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init?: RequestInit) => {
if (url === '/api/admin/reload' && init?.method === 'POST') {
return new Response(null, { status: 500 });
}
return new Response(null, { status: 500 }); // status/doctor probes
}),
);
render(
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>,
);
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
await waitFor(() => expect(alertMock).toHaveBeenCalledWith('Reload failed (HTTP 500).'));
});
it('raises the session-expired event instead of alerting on a 401', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
const listener = vi.fn();
window.addEventListener(SESSION_EXPIRED_EVENT, listener);
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init?: RequestInit) => {
if (url === '/api/admin/reload' && init?.method === 'POST') {
return new Response(null, { status: 401 });
}
return new Response(null, { status: 500 });
}),
);
render(
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>,
);
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
await waitFor(() => expect(listener).toHaveBeenCalledOnce());
expect(alertMock).not.toHaveBeenCalled();
window.removeEventListener(SESSION_EXPIRED_EVENT, listener);
});
it('does not alert when the reload succeeds', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url === '/api/admin/reload' && init?.method === 'POST') {
return new Response(null, { status: 200 });
}
return new Response(null, { status: 500 });
});
vi.stubGlobal('fetch', fetchMock);
render(
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>,
);
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(
([url, init]) => url === '/api/admin/reload' && init?.method === 'POST',
),
).toHaveLength(1),
);
expect(alertMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { SESSION_EXPIRED_EVENT, reportIfSessionExpired } from '../sessionExpiry';
/**
* Sessions last 24 hours, but nothing re-checked authentication after the
* mount-time check in AdminShell — every admin action after that just failed
* quietly with the UI still looking signed in (#596). This is the one place
* every admin fetch call site is meant to route a non-ok response through.
*/
describe('reportIfSessionExpired', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('dispatches the event and returns true for a 401', () => {
const listener = vi.fn();
window.addEventListener(SESSION_EXPIRED_EVENT, listener);
const result = reportIfSessionExpired(new Response(null, { status: 401 }));
expect(result).toBe(true);
expect(listener).toHaveBeenCalledOnce();
window.removeEventListener(SESSION_EXPIRED_EVENT, listener);
});
it('does nothing and returns false for other statuses', () => {
const listener = vi.fn();
window.addEventListener(SESSION_EXPIRED_EVENT, listener);
for (const status of [200, 400, 403, 404, 500]) {
expect(reportIfSessionExpired(new Response(null, { status }))).toBe(false);
}
expect(listener).not.toHaveBeenCalled();
window.removeEventListener(SESSION_EXPIRED_EVENT, listener);
});
});

View File

@@ -0,0 +1,87 @@
/**
* The gallery.yaml ↔ page-builder translation for album entries.
*
* Extracted from PageBuilder.tsx so it can be tested on its own: this is the
* code that decides what survives a save, and a field it does not know about
* is discarded silently rather than failing (#614).
*/
import { DEFAULT_ALBUM_SORT, isAlbumSortMode } from '@/lib/albumSort';
import { type AlbumEntryObject } from '@/lib/config/schema';
import type { AlbumEntry } from './types';
export type RawAlbumEntry = string | Record<string, string | AlbumEntryObject>;
/**
* Whether an entry carries anything beyond its ID.
*
* Both collapse rules below depend on this, and they are the reason every new
* per-album option has to be listed here: an entry that looks "empty" is
* serialized back to a bare UUID string, so a field missing from this check is
* silently dropped on the next save.
*/
export function hasAlbumOptions(entry: AlbumEntry): boolean {
return Boolean(
entry.description ||
entry.password ||
entry.heroImage ||
(entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) ||
entry.assetOrder?.length ||
entry.grid ||
entry.coverPosition ||
entry.location ||
entry.download,
);
}
export function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[] {
if (!raw) return [];
return raw.map((entry) => {
if (typeof entry === 'string') return { id: entry };
const [id, value] = Object.entries(entry)[0];
if (typeof value === 'string') return { id, title: value };
return {
id,
title: value.title,
description: value.description,
password: value.password,
heroImage: value.heroImage,
sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
assetOrder: value.assetOrder,
grid: value.grid,
coverPosition: value.coverPosition,
location: value.location,
download: value.download,
};
});
}
export function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
return entries.map((entry) => {
const extras = hasAlbumOptions(entry);
if (!entry.title && !extras) return entry.id;
if (entry.title && !extras) return { [entry.id]: entry.title };
const val: AlbumEntryObject = {};
// Only when set: a sort-only entry would otherwise be written with an empty
// title, which deriveGallery ignores but which still lands in the YAML.
if (entry.title) val.title = entry.title;
if (entry.description) val.description = entry.description;
if (entry.password) val.password = entry.password;
if (entry.heroImage) val.heroImage = entry.heroImage;
if (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) val.sort = entry.sort;
// Persisted regardless of the mode, so manual → newest → manual does not
// throw away a hand-curated order.
if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder;
if (entry.grid) val.grid = entry.grid;
if (entry.coverPosition) val.coverPosition = entry.coverPosition;
// No UI sets this yet; it is preserved so that saving an unrelated change
// cannot quietly republish exact coordinates for an album deliberately
// placed at city level or hidden from the map (#469).
if (entry.location) val.location = entry.location;
// Same story as location: set by hand in YAML since #475, with no UI, so
// the builder was turning original downloads back off on the next save.
if (entry.download) val.download = entry.download;
return { [entry.id]: val };
});
}

View File

@@ -13,6 +13,17 @@ export interface AlbumEntry {
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
/** EXPERIMENTAL: focal point for the cover crop, e.g. "50% 25%" or "top" */
coverPosition?: string;
/**
* Map precision for this album: `exact`, `city`, `country` or `hidden` (#469).
*
* Carried verbatim rather than narrowed. The builder has no UI for it yet, so
* its only job is to hand back exactly what it was given — including a value
* this version does not recognise, which deriveGallery() is the right place
* to reject.
*/
location?: string;
/** Whether visitors may download the originals from this album (#475). */
download?: boolean;
}
export interface Section {
@@ -37,6 +48,8 @@ export interface Subpage {
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
/** The album-cover tiles on this page only (#523). */
coverGrid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
/** Map precision for every album here that does not set its own (#469). */
location?: string;
}
/**

View File

@@ -0,0 +1,24 @@
/**
* Fired on `window` by an admin action whose request came back 401 — the
* session cookie expired (sessions last 24 hours) or was cleared elsewhere.
* AdminShell listens and drops back to the login screen with an explanation,
* instead of each save/reload path failing quietly while the UI still looks
* signed in (#596).
*
* Same pattern as DOCTOR_LEVEL_EVENT in systemHealth.ts: a window event
* rather than a prop or context, because the components that need to raise
* this (PageBuilder, SettingsEditor, JournalStudio, AdminDashboard) sit
* several routes below AdminShell, which owns the auth gate.
*/
export const SESSION_EXPIRED_EVENT = 'folio:admin-session-expired';
/**
* Call with the Response from an admin fetch. If it is a 401, dispatches the
* event and returns true, so the caller can skip its own generic error
* handling for that response and let AdminShell take over instead.
*/
export function reportIfSessionExpired(res: Response): boolean {
if (res.status !== 401) return false;
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT));
return true;
}

View File

@@ -1,5 +1,15 @@
import PageBuilder from './components/PageBuilder';
import { redirect } from 'next/navigation';
/**
* `/admin` and `/admin/pages` both rendered the page builder, so the same
* screen had two addresses: two routes to keep in step, two things to find
* when something about the builder changes, and no way to tell which one a
* bookmark or a bug report meant.
*
* `/admin/pages` wins because that is what the Pages tab links to. `/admin`
* stays as the entry point people type and bookmark — and it is what the
* public diagnostic banner links to — so it redirects rather than disappearing.
*/
export default function AdminHomePage() {
return <PageBuilder />;
redirect('/admin/pages');
}

View File

@@ -298,3 +298,15 @@
font-size: 0.8rem;
margin-bottom: 0.75rem;
}
/* Neutral, not an error: "your session expired" is expected behaviour after
24 hours, not a mistake the operator made (#596). */
.admin-notice {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
color: var(--admin-text-secondary);
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.8rem;
margin-bottom: 0.75rem;
}

View File

@@ -473,15 +473,6 @@ h3 .svg-icon {
border-bottom: 1px solid var(--admin-border);
}
/* ── Status Dropdown Footer ── */
.status-dropdown-footer {
padding: 0.5rem 0.85rem;
background: var(--admin-bg-elevated);
border-top: 1px solid var(--admin-border);
display: flex;
justify-content: flex-end;
}
.admin-btn-sm {
padding: 0.35rem 0.65rem !important;
font-size: 0.75rem !important;

View File

@@ -60,10 +60,6 @@
font-size: 0.78rem;
}
.help-action {
padding: 12px 4px 4px;
}
@media (max-width: 600px) {
.help-shortcuts-row {
grid-template-columns: 1fr;