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

@@ -11,14 +11,16 @@ import { albumMetaDetail } from '@/lib/albumMeta';
import { AlbumNav } from '@/components/AlbumNav';
import { StructuredData } from '@/components/StructuredData';
import type { AlbumNavPair } from '@/lib/albumNav';
import type { ProofSessionInit } from '@/components/ProofingContext';
interface AlbumDetailViewProps {
album: ImmichAlbum;
images: PhotoItem[];
layout: GridConfig['layout'];
gridStyle: React.CSSProperties;
backLinkHref: string;
backLinkLabel: string;
/** Omitted on a client proofing link, which has nowhere to go back to. */
backLinkHref?: string;
backLinkLabel?: string;
subtitle?: string;
heroImageUrl?: string;
heroBlurDataURL?: string;
@@ -31,6 +33,12 @@ interface AlbumDetailViewProps {
proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean;
/**
* `encodeEmail()` of where the proofing dialog's "email to photographer"
* goes, when one is configured (#736). Encoded, like every address the site
* renders, so the plain form stays out of the HTML and the RSC payload.
*/
encodedMailto?: string;
/**
* The ZIP endpoint for this album, when it offers downloads (#475). Renders a
* "download album" link in the header and lets the proofing modal offer a
@@ -41,6 +49,10 @@ interface AlbumDetailViewProps {
nav?: AlbumNavPair;
/** JSON-LD for this album, or null when no site URL is configured (#472). */
structuredData?: Record<string, unknown> | null;
/** A client proofing link's session; see PhotoGrid. */
proofSession?: ProofSessionInit;
/** Rendered under the title — the proofing link's greeting and notes. */
intro?: React.ReactNode;
}
export function AlbumDetailView({
@@ -58,9 +70,12 @@ export function AlbumDetailView({
showGear = true,
proofing,
allowMailto,
encodedMailto,
downloadArchiveUrl,
nav,
structuredData,
proofSession,
intro,
}: AlbumDetailViewProps) {
const metaDetail = albumMetaDetail(album, showGear);
const t = getServerDictionary();
@@ -86,7 +101,7 @@ export function AlbumDetailView({
)}
<div className={`album-header${heroImageUrl ? ' album-header--has-hero' : ''}`}>
<div className="album-header__main">
<BackLink href={backLinkHref} label={backLinkLabel} />
{backLinkHref && <BackLink href={backLinkHref} label={backLinkLabel ?? ''} />}
<h1 className="album-header__title">{album.albumName}</h1>
{subtitle && (
<p className="subpage-subtitle" style={{ textAlign: 'left', marginLeft: 0 }}>
@@ -94,6 +109,7 @@ export function AlbumDetailView({
</p>
)}
{album.description && <p className="album-header__description">{album.description}</p>}
{intro}
{downloadArchiveUrl && (
<a className="album-header__download" href={downloadArchiveUrl}>
<svg
@@ -127,8 +143,10 @@ export function AlbumDetailView({
showExifPanel={showExifPanel}
proofing={proofing}
allowMailto={allowMailto}
encodedMailto={encodedMailto}
downloadArchiveUrl={downloadArchiveUrl}
albumName={album.albumName}
proofSession={proofSession}
/>
{nav && <AlbumNav {...nav} />}
</>

View File

@@ -2,17 +2,32 @@
import { useState, useMemo } from 'react';
import Image from 'next/image';
import dynamic from 'next/dynamic';
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
import { useProofing } from '@/components/useProofing';
import { ProofingProvider, ProofingModal } from '@/components/ProofingLazy';
import { FadeIn } from '@/components/FadeIn';
import { LeafletMap, escapeHtml } from '@/components/LeafletMap';
import { escapeHtml } from '@/lib/escapeHtml';
import type { ParsedEssay, EssayBlock } from '@/lib/essay';
import { renderInlineMarkdown } from '@/lib/essay';
import { essayPhotoSequence } from '@/lib/essaySequence';
import type { PhotoItem } from './PhotoGrid';
import './essay.css';
import { useDictionary } from '@/components/I18nProvider';
import { formatJournalDate } from '@/lib/journalDate';
/**
* The map block is the only thing in an essay that needs Leaflet, and most
* essays have none. Imported statically, LeafletMap put its wrapper and
* Leaflet's stylesheet (render-blocking CSS) on every album, subpage and
* journal page. The map only ever drew on the client — Leaflet itself was
* already imported inside an effect — so the server renders the same empty
* container it rendered before, and the wrapper arrives with the map.
*/
const LeafletMap = dynamic(() => import('@/components/LeafletMap').then((m) => m.LeafletMap), {
ssr: false,
loading: () => <div className="essay-map" />,
});
interface EssayViewProps {
essay: ParsedEssay;
@@ -28,6 +43,8 @@ interface EssayViewProps {
proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean;
/** `encodeEmail()` of the proofing recipient, when one is configured (#736). */
encodedMailto?: string;
}
function EssayViewContent({
@@ -389,6 +406,9 @@ function EssayViewContent({
}
};
// The story's photos that are selected, not the whole stored set (see PhotoGrid).
const selectedCount = proofing ? proofing.getSelectedTokens().length : 0;
return (
<div className="essay-container">
<header className="essay-header">
@@ -396,7 +416,9 @@ function EssayViewContent({
{displaySubtitle && <p className="essay-header__subtitle">{displaySubtitle}</p>}
{(essay.frontmatter.author || essay.frontmatter.date) && (
<div className="essay-header__meta">
{[essay.frontmatter.author, essay.frontmatter.date].filter(Boolean).join(' • ')}
{[essay.frontmatter.author, formatJournalDate(essay.frontmatter.date, t.dateLocale)]
.filter(Boolean)
.join(' • ')}
</div>
)}
</header>
@@ -429,9 +451,9 @@ function EssayViewContent({
</figure>
)}
<main>{essay.blocks.map(renderBlock)}</main>
<div>{essay.blocks.map(renderBlock)}</div>
{proofing && proofing.favorites.size > 0 && (
{proofing && selectedCount > 0 && (
<div
className="proofing-sticky-bar"
style={{
@@ -454,7 +476,7 @@ function EssayViewContent({
{/* A count, not the album grid's filter toggle: a story's layout is
authored, so there is nothing for "show favourites" to narrow. */}
<span style={{ fontSize: '0.85rem', fontWeight: 500, padding: '6px 4px' }}>
{t.proofing.selected(proofing.favorites.size)}
{t.proofing.selected(selectedCount)}
</span>
<button
type="button"
@@ -513,6 +535,7 @@ export function EssayView(props: EssayViewProps) {
// default key with every album (and story) on the site.
albumName={props.title || props.essay.frontmatter.title}
allowMailto={props.allowMailto ?? true}
encodedMailto={props.encodedMailto}
>
<EssayViewContent {...props} />
</ProofingProvider>

View File

@@ -15,8 +15,9 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
import Image from 'next/image';
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
import { FadeIn } from '@/components/FadeIn';
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
import type { ProofSessionInit } from '@/components/ProofingContext';
import { useProofing } from '@/components/useProofing';
import { ProofingProvider, ProofingModal, ProofSessionControls } from '@/components/ProofingLazy';
import { useDictionary } from '@/components/I18nProvider';
import { parsePhotoHash, parsePhotoQuery, buildPhotoQuery } from '@/lib/photoHash';
@@ -60,6 +61,12 @@ interface PhotoGridProps {
proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean;
/**
* `encodeEmail()` of where the proofing dialog's "email to photographer"
* goes, when one is configured (#736). Lets the modal address the compose;
* without it the button is hidden.
*/
encodedMailto?: string;
/**
* The ZIP endpoint for this album, when it offers downloads. Lets the
* proofing modal offer a "download selected" action (#475).
@@ -71,6 +78,11 @@ interface PhotoGridProps {
* selection made in one album leaks into the next.
*/
albumName?: string;
/**
* A client proofing link's session (/proof/<token>). Turns proofing on and
* keeps the selection on the server instead of in the browser.
*/
proofSession?: ProofSessionInit;
}
function PhotoGridInner({
@@ -124,6 +136,33 @@ function PhotoGridInner({
}
}, [lightboxIndex, displayedAssets]);
// ── The photo on screen can leave the list ─────────────────────
// With "selected only" on, un-hearting the photo in the viewer drops it from
// `displayedAssets`. The viewer keeps its position, which now shows the next
// favourite; past the end it steps back to the last one, and with nothing
// left it closes. `shownIndex` keeps this render valid (it used to hand the
// viewer an index past the end, and the page crashed); the effect settles
// the state and the URL.
const shownIndex =
lightboxIndex === null || displayedAssets.length === 0
? null
: Math.min(lightboxIndex, displayedAssets.length - 1);
useEffect(() => {
if (lightboxIndex === null || lightboxIndex < displayedAssets.length) return;
if (displayedAssets.length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLightboxIndex(displayedAssets.length - 1);
return;
}
setLightboxIndex(null);
window.history.replaceState(
null,
'',
window.location.pathname + buildPhotoQuery(window.location.search, null),
);
}, [lightboxIndex, displayedAssets.length]);
// ── Listen for browser back/forward ────────────────────────────
useEffect(() => {
const handlePopState = () => {
@@ -204,6 +243,7 @@ function PhotoGridInner({
}}
role="button"
tabIndex={0}
data-lightbox-index={index}
aria-label={t.lightbox.openPhoto(index + 1)}
aria-haspopup="dialog"
style={{
@@ -285,13 +325,38 @@ function PhotoGridInner({
});
}, [displayedAssets, layout, openLightbox, proofing, t]);
// Photos of this album that are selected. Not `favorites.size`: the stored set
// can also hold a photo since removed, or a favourite from another album whose
// name slugs the same way (they share `folio_fav_<name>`).
const selectedCount = proofing ? proofing.getSelectedTokens().length : 0;
return (
<>
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle}>
{gridItems}
</div>
{proofing?.isFilterActive && displayedAssets.length === 0 ? (
// Un-hearting the last favourite with the filter on left a blank page
// between the header and the footer. Say why, and offer the way back
// here as well as in the bar in the corner.
<div className="empty-state" role="status">
<p className="empty-state__text">{t.proofing.filterEmpty}</p>
<div className="empty-state__actions">
<button
type="button"
className="empty-state__button"
onClick={() => proofing.setIsFilterActive(false)}
>
{t.proofing.showAll}
</button>
</div>
</div>
) : (
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle} data-lightbox-group>
{gridItems}
</div>
)}
{proofing && proofing.favorites.size > 0 && (
{/* Kept while the filter is on: un-hearting the last favourite there
empties the grid, and the bar holds the only way back to all photos. */}
{proofing && !proofing.session && (selectedCount > 0 || proofing.isFilterActive) && (
<div
className="proofing-sticky-bar"
style={{
@@ -327,9 +392,7 @@ function PhotoGridInner({
cursor: 'pointer',
}}
>
{proofing.isFilterActive
? t.proofing.showAll
: t.proofing.selected(proofing.favorites.size)}
{proofing.isFilterActive ? t.proofing.showAll : t.proofing.selected(selectedCount)}
</button>
<button
type="button"
@@ -349,12 +412,13 @@ function PhotoGridInner({
</div>
)}
{proofing && <ProofingModal />}
{proofing && !proofing.session && <ProofingModal />}
{proofing?.session && <ProofSessionControls />}
{lightboxIndex !== null && (
{shownIndex !== null && (
<Lightbox
assets={displayedAssets}
currentIndex={lightboxIndex}
currentIndex={shownIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
@@ -372,7 +436,7 @@ export function PhotoGrid(props: PhotoGridProps) {
// Without the provider useProofing() returns null, and every proofing control
// (hearts in the grid and in the lightbox, selection bar, modal) drops out on
// its own.
if (!props.proofing) {
if (!props.proofing && !props.proofSession) {
return <PhotoGridInner {...props} />;
}
@@ -381,7 +445,9 @@ export function PhotoGrid(props: PhotoGridProps) {
albumTokens={albumTokens}
albumName={props.albumName}
allowMailto={props.allowMailto ?? true}
encodedMailto={props.encodedMailto}
downloadArchiveUrl={props.downloadArchiveUrl}
session={props.proofSession}
>
<PhotoGridInner {...props} />
</ProofingProvider>

View File

@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
/**
* With "show selected" on, the lightbox walks the favourites only. Un-hearting
* the photo on screen takes it out of that list while the viewer is open: on
* the last favourite the index pointed past the end and the viewer crashed the
* page (`current` undefined), and on the only one the list was empty.
*/
vi.mock('next/image', () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
}));
vi.mock('@/components/FadeIn', () => ({
FadeIn: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const photo = (id: string): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 1.5,
});
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
let store: Map<string, string>;
beforeEach(() => {
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the test a working one.
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.history.replaceState(null, '', '/wedding');
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
async function openFilteredLightboxOn(n: number) {
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
fireEvent.click(await screen.findByText(/❤️ \d Selected/));
fireEvent.click(screen.getByLabelText(`View photo ${n}`));
return screen.getByRole('dialog');
}
const shownPhoto = (dialog: HTMLElement) =>
dialog.querySelector('img')?.getAttribute('src')?.split('?')[0];
describe('un-hearting inside the filtered lightbox', () => {
it('moves to the previous favourite when the last one is removed', async () => {
store.set('folio_fav_wedding', JSON.stringify(['a1', 'a3']));
const dialog = await openFilteredLightboxOn(2);
expect(shownPhoto(dialog)).toBe('/p/a3');
fireEvent.click(within(dialog).getByLabelText('Remove from favorites'));
const after = screen.getByRole('dialog');
expect(shownPhoto(after)).toBe('/p/a1');
expect(within(after).getByText('1 / 1')).toBeTruthy();
expect(new URLSearchParams(window.location.search).get('photo')).toBe('a1');
});
it('closes the viewer when the only favourite is removed', async () => {
store.set('folio_fav_wedding', JSON.stringify(['a2']));
const dialog = await openFilteredLightboxOn(1);
fireEvent.click(within(dialog).getByLabelText('Remove from favorites'));
expect(screen.queryByRole('dialog')).toBeNull();
expect(new URLSearchParams(window.location.search).get('photo')).toBeNull();
});
});

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { JournalBlock } from '@/lib/journal';
const getAlbumAssetsRaw = vi.fn();
vi.mock('@/lib/immich', () => ({
immich: {
get getAlbumAssetsRaw() {
return getAlbumAssetsRaw;
},
},
}));
import { loadAlbumBlocks } from '../essayPayload';
const album = (albumId: string) => ({ type: 'album', albumId, line: 0 }) as unknown as JournalBlock;
const photo = { type: 'photo', line: 0 } as unknown as JournalBlock;
describe('loadAlbumBlocks', () => {
beforeEach(() => {
getAlbumAssetsRaw.mockReset();
});
// It used to await each album before asking for the next, so a page with
// three album blocks waited for three Immich round trips in a row.
it('asks for every album before any answer arrives', async () => {
const pending: Array<() => void> = [];
getAlbumAssetsRaw.mockImplementation(
(id: string) =>
new Promise((resolve) => pending.push(() => resolve([{ id: `${id}-asset` }]))),
);
const result = loadAlbumBlocks([album('a'), photo, album('b'), album('c')], () => {});
await Promise.resolve();
expect(getAlbumAssetsRaw).toHaveBeenCalledTimes(3);
pending.forEach((resolve) => resolve());
expect([...(await result).keys()]).toEqual(['a', 'b', 'c']);
});
it('loads an album named by two blocks once', async () => {
getAlbumAssetsRaw.mockResolvedValue([]);
await loadAlbumBlocks([album('a'), album('a')], () => {});
expect(getAlbumAssetsRaw).toHaveBeenCalledTimes(1);
});
it('leaves out an album that fails and reports it, keeping the rest', async () => {
const failure = new Error('immich down');
getAlbumAssetsRaw.mockImplementation(async (id: string) => {
if (id === 'b') throw failure;
return [{ id: `${id}-asset` }];
});
const onError = vi.fn();
const result = await loadAlbumBlocks([album('a'), album('b'), album('c')], onError);
expect([...result.keys()]).toEqual(['a', 'c']);
expect(onError).toHaveBeenCalledWith('b', failure);
});
});

View File

@@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Metadata } from 'next';
/**
* generateMetadata runs for any URL a visitor types. It used to start from
* `title = slug` and keep that whenever nothing matched, so an unknown path,
* an offline subpage or a locked album put the requested slug in the
* `<title>` and in a validly signed share-card URL. Every branch that finds
* nothing — or finds it locked — now answers with a dictionary title and no
* card of its own.
*/
vi.mock('next/navigation', () => ({
notFound: () => {
throw new Error('NEXT_NOT_FOUND');
},
}));
vi.mock('next/headers', () => ({
cookies: async () => ({ get: () => undefined }),
}));
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => false }));
vi.mock('@/lib/i18n/server', async () => {
const { getDictionary } = await import('@/lib/i18n');
return { getServerDictionary: () => getDictionary('en') };
});
vi.mock('@/lib/ogImage', () => ({
ogImageUrl: (title: string, subtitle = '') =>
`/api/og?title=${encodeURIComponent(title)}&subtitle=${encodeURIComponent(subtitle)}&sig=x`,
}));
vi.mock('../contentPage', () => ({
contentPageMetadata: vi.fn(async () => null),
renderContentPage: vi.fn(async () => null),
}));
const locked = vi.hoisted(() => ({ keys: new Set<string>() }));
vi.mock('@/lib/auth', () => ({
isProtected: (key: string) => locked.keys.has(key),
isAuthenticated: () => false,
withoutLockedAlbums: <T>(albums: T[]) => albums,
}));
const immich = vi.hoisted(() => ({
isSubpageSlug: vi.fn((slug: string) => slug === 'travel' || slug === 'solo'),
getSubpageAlbums: vi.fn(),
getAlbumBySlug: vi.fn(),
}));
vi.mock('@/lib/immich', () => ({ immich }));
const ICELAND = {
id: 'album-1',
albumName: 'Iceland',
slug: 'iceland',
assets: [{ id: 'a1', type: 'IMAGE' }],
albumThumbnailAssetId: null,
};
import { generateMetadata } from '../page';
import { getDictionary } from '@/lib/i18n';
const en = getDictionary('en');
const meta = (path: string[]) =>
generateMetadata({ params: Promise.resolve({ path }), searchParams: Promise.resolve({}) });
/** Every string the metadata would put in the page head or a share URL. */
function emitted(m: Metadata): string {
return JSON.stringify(m);
}
beforeEach(() => {
vi.clearAllMocks();
locked.keys.clear();
immich.getAlbumBySlug.mockImplementation(async (slug: string) =>
slug === 'iceland' ? ICELAND : null,
);
immich.getSubpageAlbums.mockImplementation(async (slug: string) => {
if (slug === 'travel') {
return {
subpage: { slug: 'travel', name: 'travel', title: 'Travel', albumIds: ['album-1', 'b'] },
albums: [ICELAND, { ...ICELAND, id: 'b', slug: 'norway', albumName: 'Norway' }],
};
}
if (slug === 'solo') {
return { subpage: { slug: 'solo', name: 'solo', albumIds: ['album-1'] }, albums: [ICELAND] };
}
return null;
});
});
describe('page metadata never echoes the requested path', () => {
it.each([
['an unknown slug', ['unknown-visitor-text']],
['an unknown album under a real subpage', ['travel', 'nope']],
['a three-segment path', ['iceland', 'x', 'y']],
])('gives %s the generic not-found title and no card', async (_, path) => {
const m = await meta(path);
expect(m.title).toBe(en.error.notFoundTitle);
expect(m.openGraph).toBeUndefined();
expect(m.twitter).toBeUndefined();
for (const segment of path) expect(emitted(m)).not.toContain(encodeURIComponent(segment));
});
it('drops the layout robots tag on a not-found path, leaving only Next’s noindex', async () => {
const m = await meta(['nope']);
expect(m.robots).toBeNull();
});
it('treats an offline subpage like any unknown slug', async () => {
// isSubpageSlug is false for `enabled: false`, and no album has the slug.
const m = await meta(['dubai']);
expect(m.title).toBe(en.error.notFoundTitle);
expect(emitted(m)).not.toContain('dubai');
});
it('gives a locked standalone album the generic protected title', async () => {
locked.keys.add('album-1');
const m = await meta(['iceland']);
expect(m.title).toBe(en.password.protectedPage);
expect(emitted(m)).not.toContain('iceland');
expect(emitted(m)).not.toContain('Iceland');
expect(m.openGraph).toBeUndefined();
});
it('gives a locked multi-album subpage the protected title, not its slug', async () => {
locked.keys.add('travel');
const m = await meta(['travel']);
expect(m.title).toBe(en.password.protectedPage);
expect(emitted(m)).not.toContain('travel');
});
it('gives an album under a locked subpage the protected title', async () => {
locked.keys.add('travel');
const m = await meta(['travel', 'iceland']);
expect(m.title).toBe(en.password.protectedPage);
expect(emitted(m)).not.toContain('Iceland');
});
it('gives a locked album on a single-album subpage the protected title', async () => {
locked.keys.add('album-1');
const m = await meta(['solo']);
expect(m.title).toBe(en.password.protectedPage);
expect(emitted(m)).not.toContain('solo');
});
it('still describes published content by its own name', async () => {
const album = await meta(['iceland']);
expect(album.title).toBe('Iceland');
expect(emitted(album)).toContain(encodeURIComponent('Iceland'));
const subpage = await meta(['travel']);
expect(subpage.title).toBe('Travel');
const nested = await meta(['travel', 'iceland']);
expect(nested.title).toBe('Iceland');
});
});

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The catch-all answers one segment (subpage, content page, standalone album)
* and two (subpage/album). A third segment used to fall through to the
* one-segment branch, which only reads `path[0]`: `/travel/iceland/anything`
* rendered the Travel subpage and `/iceland/x/y` the Iceland album, each with
* a 200, under an endless supply of URLs.
*/
const NOT_FOUND = new Error('NEXT_NOT_FOUND');
vi.mock('next/navigation', () => ({
notFound: () => {
throw NOT_FOUND;
},
}));
vi.mock('next/headers', () => ({
cookies: async () => ({ get: () => undefined }),
}));
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => false }));
const immich = vi.hoisted(() => ({
isSubpageSlug: vi.fn((slug: string) => slug === 'travel'),
getSubpageAlbums: vi.fn(),
getAlbumBySlug: vi.fn(),
getStandaloneAlbums: vi.fn(async () => []),
getSubpages: vi.fn(async () => []),
getAssetInfo: vi.fn(async () => null),
}));
vi.mock('@/lib/immich', () => ({ immich }));
const ALBUM = {
id: 'album-1',
albumName: 'Iceland',
slug: 'iceland',
assets: [],
albumThumbnailAssetId: null,
};
import PathPage, { generateMetadata } from '../page';
import { getServerDictionary } from '@/lib/i18n/server';
const call = (path: string[]) => ({
params: Promise.resolve({ path }),
searchParams: Promise.resolve({}),
});
beforeEach(() => {
vi.clearAllMocks();
immich.getAlbumBySlug.mockResolvedValue(ALBUM);
immich.getSubpageAlbums.mockResolvedValue({
subpage: { slug: 'travel', name: 'Travel', albumIds: ['album-1', 'album-2'] },
albums: [ALBUM, { ...ALBUM, id: 'album-2', slug: 'norway', albumName: 'Norway' }],
});
});
describe('catch-all path depth', () => {
it.each([[['iceland', 'x', 'y']], [['travel', 'iceland', 'extra']], [['travel', 'a', 'b', 'c']]])(
'answers %j with a 404',
async (path) => {
await expect(PathPage(call(path))).rejects.toBe(NOT_FOUND);
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
expect(immich.getSubpageAlbums).not.toHaveBeenCalled();
},
);
it('gives a three-segment path no album metadata, only the not-found title', async () => {
const meta = await generateMetadata(call(['iceland', 'x', 'y']));
expect(meta.title).toBe(getServerDictionary().error.notFoundTitle);
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
});
it('still renders one and two segments', async () => {
await expect(PathPage(call(['iceland']))).resolves.toBeTruthy();
await expect(PathPage(call(['travel', 'iceland']))).resolves.toBeTruthy();
});
});

View File

@@ -0,0 +1,121 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
import { EssayView } from '../EssayView';
import { parseEssayMarkdown } from '@/lib/essay';
import { encodeEmail } from '@/lib/emailObfuscation';
/**
* The anonymous proofing selection lives under `folio_fav_<albumName>`, so it
* can hold tokens this grid does not show: a photo since removed from the
* album, or a favourite from another album whose name slugs the same way.
* Every count the visitor sees must be the photos of *this* album that are
* selected — the modal's download button already counted that way, while the
* bar, the title and the email subject counted the whole stored set.
*/
vi.mock('next/image', () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
}));
vi.mock('@/components/Lightbox', () => ({ Lightbox: () => null }));
vi.mock('@/components/LeafletMap', () => ({
LeafletMap: () => null,
escapeHtml: (s: string) => s,
}));
vi.mock('@/components/FadeIn', () => ({
FadeIn: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const photo = (id: string): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 1.5,
});
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
let store: Map<string, string>;
beforeEach(() => {
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the test a working one.
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(),
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('proofing counts in the album grid', () => {
it('counts only this album’s photos, not stale or foreign favourites', async () => {
// One photo of this album, one removed photo, one from a same-named album.
store.set('folio_fav_wedding', JSON.stringify(['a2', 'gone', 'other-album']));
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
expect(await screen.findByText('❤️ 1 Selected')).toBeTruthy();
fireEvent.click(screen.getByText('Share & Export'));
expect(screen.getByText('❤️ Selection (1)')).toBeTruthy();
expect(screen.getByText(/You have selected 1 photo\./)).toBeTruthy();
});
it('shows no selection bar when only foreign favourites are stored', () => {
store.set('folio_fav_wedding', JSON.stringify(['gone', 'other-album']));
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
// `render` flushes the hydration effect, so the stored set is loaded here.
expect(screen.queryByText(/Selected/)).toBeNull();
expect(screen.queryByText('Share & Export')).toBeNull();
});
it('keeps "Show All" reachable after the last favourite is removed in the filter', async () => {
store.set('folio_fav_wedding', JSON.stringify(['a1']));
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
fireEvent.click(await screen.findByText('❤️ 1 Selected'));
// Filtered to the one favourite; un-heart it and the grid is empty.
fireEvent.click(screen.getByLabelText('Remove favorite'));
expect(screen.queryByLabelText('Add favorite')).toBeNull();
// The empty state offers "Show All" too; this is about the bar's own.
const bar = document.querySelector<HTMLElement>('.proofing-sticky-bar')!;
fireEvent.click(within(bar).getByText('Show All'));
expect(screen.getAllByLabelText('Add favorite')).toHaveLength(3);
});
it('puts the album count into the email subject', async () => {
store.set('folio_fav_wedding', JSON.stringify(['a1', 'gone']));
render(
<PhotoGrid
assets={ASSETS}
proofing
albumName="Wedding"
allowMailto
encodedMailto={encodeEmail('hello@example.com')}
/>,
);
fireEvent.click(await screen.findByText('Share & Export'));
const location = { href: 'http://localhost/wedding' };
vi.stubGlobal('location', location);
fireEvent.click(screen.getByText('✉️ Send Email to Photographer'));
expect(decodeURIComponent(location.href)).toMatch(/Photo Selection \(1 items?\)/);
});
});
describe('proofing counts in an essay', () => {
it('counts only the essay’s photos', async () => {
store.set('folio_fav_story', JSON.stringify(['a3', 'gone']));
const essay = parseEssayMarkdown('---\ntitle: Story\n---\n\n![a1, a3](caption)\n');
render(<EssayView essay={essay} assets={ASSETS} title="Story" proofing />);
expect(await screen.findByText('❤️ 1 Selected')).toBeTruthy();
});
});

View File

@@ -0,0 +1,77 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
/**
* With "show selected" on, un-hearting the last favourite emptied the grid and
* left nothing between the album header and the footer — no word on why, and
* the only way back a small pill in the corner.
*/
vi.mock('next/image', () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
}));
vi.mock('@/components/FadeIn', () => ({
FadeIn: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const photo = (id: string): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 1.5,
});
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
let store: Map<string, string>;
beforeEach(() => {
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the test a working one.
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.history.replaceState(null, '', '/wedding');
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('favourites filter with nothing left', () => {
it('says nothing is selected and offers the way back to all photos', async () => {
store.set('folio_fav_wedding', JSON.stringify(['a2']));
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
fireEvent.click(await screen.findByText('❤️ 1 Selected'));
expect(screen.getAllByLabelText(/^View photo/)).toHaveLength(1);
fireEvent.click(screen.getByLabelText('Remove favorite'));
const empty = screen.getByRole('status');
expect(within(empty).getByText('No photos selected.')).toBeTruthy();
expect(screen.queryAllByLabelText(/^View photo/)).toHaveLength(0);
fireEvent.click(within(empty).getByRole('button', { name: 'Show All' }));
expect(screen.getAllByLabelText(/^View photo/)).toHaveLength(3);
expect(screen.queryByText('No photos selected.')).toBeNull();
});
it('shows no empty state while the filter still has photos', async () => {
store.set('folio_fav_wedding', JSON.stringify(['a1', 'a2']));
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
fireEvent.click(await screen.findByText('❤️ 2 Selected'));
expect(screen.getAllByLabelText(/^View photo/)).toHaveLength(2);
expect(screen.queryByText('No photos selected.')).toBeNull();
});
});

View File

@@ -0,0 +1,126 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { renderToReadableStream } from 'react-dom/server';
import type { ReactNode } from 'react';
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
import { EssayView } from '../EssayView';
import { parseEssayMarkdown } from '@/lib/essay';
/**
* Proofing is off on most album pages, but its provider, modal and session
* controls used to be imported statically by the grid and the essay, so every
* album, essay and journal page shipped them (~5 KB gzip). They now load
* through `next/dynamic` only where proofing is on.
*
* The mocks below pass every module through unchanged and only note that it
* was loaded: a page without proofing must not load any of them, and a page
* with proofing must still render the hearts — on the server too, so they are
* in the HTML before the chunk arrives.
*/
const loaded = vi.hoisted(() => new Set<string>());
vi.mock('@/components/ProofingContext', async (importOriginal) => {
loaded.add('ProofingContext');
return importOriginal();
});
vi.mock('@/components/ProofingModal', async (importOriginal) => {
loaded.add('ProofingModal');
return importOriginal();
});
vi.mock('@/components/ProofSessionControls', async (importOriginal) => {
loaded.add('ProofSessionControls');
return importOriginal();
});
// The build aliases `next/dynamic` to the App Router implementation in app/;
// the bare package resolves to the Pages Router one.
vi.mock('next/dynamic', async () => ({
default: (await import('next/dist/shared/lib/app-dynamic')).default,
}));
vi.mock('next/image', () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
}));
vi.mock('@/components/LeafletMap', () => ({
LeafletMap: () => null,
escapeHtml: (s: string) => s,
}));
vi.mock('@/components/FadeIn', () => ({
FadeIn: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
const photo = (id: string): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 1.5,
});
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
const ESSAY = parseEssayMarkdown('---\ntitle: Story\n---\n\n![a1, a3](caption)\n');
async function serverHtml(node: ReactNode): Promise<string> {
const stream = await renderToReadableStream(node);
await stream.allReady;
return new Response(stream).text();
}
const hearts = (html: string) => html.match(/aria-label="Add favorite"/g)?.length ?? 0;
beforeEach(() => {
// Node defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the test 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(),
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// Runs first: the set records loads for the whole file.
describe('proofing off', () => {
it('loads neither the provider nor the modal nor the session controls', async () => {
const grid = render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
const essay = render(<EssayView essay={ESSAY} assets={ASSETS} title="Story" />);
expect(grid.container.querySelectorAll('img')).toHaveLength(3);
expect(essay.container.querySelectorAll('img')).toHaveLength(2);
expect(screen.queryByLabelText('Add favorite')).toBeNull();
await serverHtml(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
expect([...loaded]).toEqual([]);
});
});
describe('proofing on', () => {
it('renders the hearts on the server', async () => {
const html = await serverHtml(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
expect(hearts(html)).toBe(3);
const essay = await serverHtml(
<EssayView essay={ESSAY} assets={ASSETS} title="Story" proofing />,
);
expect(hearts(essay)).toBe(2);
});
it('shares one context between the grid and the lazily loaded provider and modal', async () => {
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
const [first] = await screen.findAllByLabelText('Add favorite');
fireEvent.click(first);
// The heart, the selection bar and the modal all read the same state.
expect(screen.getAllByLabelText('Remove favorite')).toHaveLength(1);
fireEvent.click(await screen.findByText('Share & Export'));
expect(await screen.findByText(/You have selected 1 photo\./)).toBeTruthy();
expect(loaded).toContain('ProofingContext');
expect(loaded).toContain('ProofingModal');
});
});

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
/**
* The page renders albums and essays from several branches — subpage album,
* essay, single-album subpage, standalone album — and each one passes the
* proofing props by hand. The dialog hides its email button without a
* recipient (#736), so a branch that forwards `allowMailto` but forgets
* `encodedMailto` silently loses the button on that kind of page. #746 missed
* two of the four; this keeps the next branch from doing the same.
*/
const source = fs.readFileSync(path.join(__dirname, '..', 'page.tsx'), 'utf8');
/** Every `<AlbumDetailView … />` and `<EssayView … />` element in the page. */
const elements = source.match(/<(AlbumDetailView|EssayView)\b[\s\S]*?\/>/g) ?? [];
describe('proofing email wiring in the catch-all page', () => {
it('finds the album and essay views', () => {
expect(elements.length).toBeGreaterThanOrEqual(4);
});
it('passes the recipient wherever it passes the email switch', () => {
const missing = elements
.filter((el) => el.includes('allowMailto='))
.filter((el) => !el.includes('encodedMailto='))
.map((el) => el.split('\n').slice(0, 3).join(' ').trim());
expect(missing).toEqual([]);
});
});

View File

@@ -0,0 +1,108 @@
/**
* Content pages at `/<slug>` (#722): content/pages/<slug>.md rendered through
* the journal's EssayView, without a date, reading time or journal index.
*
* Draft and password behave as on a journal entry: a draft is a 404 for
* everyone but a signed-in admin, and a password shows the gate until the
* `lb_auth_page_<slug>` cookie is set. The site password is enforced before
* any of this runs, in proxy.ts.
*/
import type { Metadata } from 'next';
import { cookies } from 'next/headers';
import { readPage, type PageRecord } from '@/lib/admin/pages-service';
import { isAdminAuthenticated } from '@/lib/admin/auth';
import { isAuthenticated } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server';
import { ogImageUrl } from '@/lib/ogImage';
import PasswordGate from '@/components/PasswordGate';
import { EssayView } from './EssayView';
import { buildEssayPayload } from './essayPayload';
type Access = 'open' | 'draft' | 'locked';
async function accessFor(page: PageRecord): Promise<Access> {
const { draft, password } = page.parsed.frontmatter;
if (draft && !(await isAdminAuthenticated())) return 'draft';
if (password) {
const cookieStore = await cookies();
if (!isAuthenticated(page.slug, (name) => cookieStore.get(name)?.value, 'page')) {
return 'locked';
}
}
return 'open';
}
/**
* Metadata for `/<slug>` when it is a page, or null when it is not.
*
* A draft is described as not found, and a locked page by a generic
* title only: generateMetadata runs ahead of the page's own gate, so the real
* title and description must not reach the `<head>` (GHSA-fvgv-97g3-wjr7).
*/
export async function contentPageMetadata(slug: string): Promise<Metadata | null> {
const page = await readPage(slug).catch(() => null);
if (!page) return null;
const access = await accessFor(page);
const t = getServerDictionary();
// A draft answers 404 to everyone but an admin, so it gets the not-found
// title, and no robots tag that would contradict Next's own noindex.
if (access === 'draft') return { title: t.error.notFoundTitle, robots: null };
if (access === 'locked') {
return { title: t.password.protectedPage, robots: { index: false } };
}
const { title: rawTitle, description } = page.parsed.frontmatter;
const title = rawTitle || slug;
const ogImage = ogImageUrl(title);
return {
title,
...(description ? { description } : {}),
openGraph: { title, ...(description ? { description } : {}), images: [ogImage] },
twitter: {
card: 'summary_large_image',
title,
...(description ? { description } : {}),
images: [ogImage],
},
};
}
/**
* The rendered page, `'not-found'` for a draft a visitor may not see, or null
* when no page has this slug (the caller then tries a standalone album).
*/
export async function renderContentPage(
slug: string,
): Promise<React.ReactElement | 'not-found' | null> {
const page = await readPage(slug).catch(() => null);
if (!page) return null;
const access = await accessFor(page);
if (access === 'draft') return 'not-found';
const { frontmatter, blocks } = page.parsed;
if (access === 'locked') {
return <PasswordGate slug={slug} title={frontmatter.title || slug} type="page" />;
}
// No map block in v1 (#722): it is dropped before any of its photos are
// fetched, so a hand-written `::map` cannot publish positions either.
const { essay, images } = await buildEssayPayload(blocks, {
logTag: 'page',
slug,
allowMap: false,
});
return (
<div className="content-page" style={{ paddingTop: '2rem' }}>
<EssayView
// Only the title crosses to the client: the frontmatter also holds
// the password, stored hashed but still not the visitor's business.
essay={{ ...essay, frontmatter: { title: frontmatter.title } }}
assets={images}
title={frontmatter.title}
watermark={getConfig().watermark}
/>
</div>
);
}

View File

@@ -0,0 +1,197 @@
/**
* Turn a parsed block-markdown document into what `EssayView` renders: the
* blocks with every asset id replaced by its token, and the photo items those
* tokens resolve to. Shared by journal entries and content pages (#722).
*
* Server only — it talks to Immich and encrypts asset ids.
*/
import {
collectAssetIds,
mapBlockAssetIds,
type JournalBlock,
type MapPin,
type ParsedJournal,
} from '@/lib/journal';
import { entryMapPins } from '@/lib/journalMap';
import { expandAlbumBlocks } from '@/lib/journalAlbum';
import { getConfig } from '@/lib/config';
import { immich, type ImmichAsset } from '@/lib/immich';
import {
imageUrl,
videoUrl,
exifUrl,
assetPlaceholder,
assetAspectRatio,
assetCaption,
assetExifSummary,
} from '@/lib/urls';
import { encodeAssetId } from '@/lib/tokens';
import type { PhotoItem } from './PhotoGrid';
export interface EssayPayload {
/** Blocks and cover carry tokens, never raw asset ids. Frontmatter is left empty. */
essay: ParsedJournal;
images: PhotoItem[];
}
interface EssayPayloadOptions {
/** Prefix for log lines, e.g. `journal` or `page`. */
logTag: string;
slug: string;
/** Map blocks are dropped when false, whatever the site's map setting. */
allowMap: boolean;
/** Raw asset id of a cover photo, shown in the lightbox sequence. */
coverAssetId?: string;
}
/**
* The assets of every album an album block names, keyed by album id, loaded
* concurrently. An album that fails is reported and left out, so its block
* drops rather than the page.
*/
export async function loadAlbumBlocks(
blocks: readonly JournalBlock[],
onError: (albumId: string, error: unknown) => void,
): Promise<Map<string, ImmichAsset[]>> {
const albumIds = [
...new Set(blocks.flatMap((b) => (b.type === 'album' && b.albumId ? [b.albumId] : []))),
];
const loaded = await Promise.all(
albumIds.map(async (albumId) => {
try {
return [albumId, await immich.getAlbumAssetsRaw(albumId)] as const;
} catch (error) {
onError(albumId, error);
return null;
}
}),
);
return new Map(loaded.filter((entry) => entry !== null));
}
export async function buildEssayPayload(
authoredBlocks: JournalBlock[],
{ logTag, slug, allowMap, coverAssetId }: EssayPayloadOptions,
): Promise<EssayPayload & { coverToken?: string }> {
const config = getConfig();
const mapAllowed = allowMap && config.map;
const sourceBlocks = allowMap ? authoredBlocks : authoredBlocks.filter((b) => b.type !== 'map');
const referencedAssetIds = [
...new Set([...(coverAssetId ? [coverAssetId] : []), ...collectAssetIds(sourceBlocks)]),
];
const fetchedAssets = (
await Promise.all(
referencedAssetIds.map((assetId) => immich.getAssetInfo(assetId).catch(() => null)),
)
).filter((a): a is ImmichAsset => a !== null);
// Album blocks become photo blocks here. The raw fetch bypasses the album
// allowlist on purpose: an entry may already show any single photo by id,
// and the author's pick is the gate for a whole album just the same. An
// album Immich cannot deliver drops its block rather than the page.
// All albums at once, not one round trip after the other.
const albumAssetsById = await loadAlbumBlocks(sourceBlocks, (albumId, error) => {
// eslint-disable-next-line no-console
console.warn(`[${logTag}] ${slug}: album ${albumId} could not be loaded:`, error);
});
const { blocks } = expandAlbumBlocks(
sourceBlocks,
(albumId) => albumAssetsById.get(albumId),
config.albumManualOrders,
);
const knownIds = new Set(fetchedAssets.map((a) => a.id));
const rawAssets = [...fetchedAssets];
for (const list of albumAssetsById.values()) {
for (const asset of list) {
if (knownIds.has(asset.id)) continue;
knownIds.add(asset.id);
rawAssets.push(asset);
}
}
// EssayView drops photo blocks it cannot resolve, which is right for visitors
// but leaves no trace of *why* a photo vanished. Legacy positional references
// ("1", "2") are the usual cause: they only resolved against a subpage album.
if (fetchedAssets.length < referencedAssetIds.length) {
const resolved = new Set(fetchedAssets.map((a) => a.id));
const missing = referencedAssetIds.filter((id) => !resolved.has(id));
// eslint-disable-next-line no-console
console.warn(
`[${logTag}] ${slug}: ${missing.length} photo reference(s) could not be resolved and will not render: ${missing.join(', ')}`,
);
}
/*
* EssayView resolves a block's photo through PhotoItem.id, and those ids are
* encrypted tokens — while the blocks coming out of the Markdown carry raw
* asset UUIDs. Translating here also keeps raw UUIDs out of the client
* payload. Unresolvable references collapse to an empty string, which
* EssayView skips.
*/
const tokenByAssetId = new Map(rawAssets.map((a) => [a.id, encodeAssetId(a.id)]));
const toToken = (assetId: string) => tokenByAssetId.get(assetId) ?? '';
// A map block's pins are computed here, from the photos fetched above and
// under each photo's `location:` precision, and only when the site
// publishes a map at all. The client gets pins only — never the items,
// which carry raw asset ids.
const entryPhotoIds = collectAssetIds(blocks.filter((b) => b.type !== 'map'));
const pinsByBlock = new Map<JournalBlock, MapPin[]>();
if (mapAllowed) {
for (const block of blocks) {
if (block.type === 'map') {
pinsByBlock.set(block, await entryMapPins(block.items, rawAssets, entryPhotoIds));
}
}
}
// Photos that only anchor a map pin are fetched for their EXIF but are not
// part of the story, so they stay out of the lightbox sequence.
const shownAssetIds = new Set([...entryPhotoIds, ...(coverAssetId ? [coverAssetId] : [])]);
const essay: ParsedJournal = {
frontmatter: {},
blocks: blocks.flatMap((block): JournalBlock[] => {
if (block.type === 'map') {
const pins = pinsByBlock.get(block);
return pins
? [{ type: 'map', caption: block.caption, line: block.line, items: [], pins }]
: [];
}
return [mapBlockAssetIds(block, toToken)];
}),
referencedAssetIds: rawAssets.map((a) => encodeAssetId(a.id)),
};
const images: PhotoItem[] = rawAssets
.filter((a) => shownAssetIds.has(a.id) && (a.type === 'IMAGE' || a.type === 'VIDEO'))
.map((a) => {
const ph = assetPlaceholder(a);
const exif =
config.exif.onHover && config.exif.camera && a.type === 'IMAGE'
? assetExifSummary(a)
: undefined;
const caption = assetCaption(a, config.exif.caption);
const isVideo = a.type === 'VIDEO';
return {
id: encodeAssetId(a.id),
type: isVideo ? 'video' : 'image',
thumbUrl: imageUrl(a.id, 'preview'),
previewUrl: imageUrl(a.id, 'preview'),
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
exifUrl: exifUrl(a.id),
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
...(exif ?? {}),
...(caption ? { caption } : {}),
aspectRatio: assetAspectRatio(a),
};
});
return {
essay,
images,
...(coverAssetId ? { coverToken: toToken(coverAssetId) || undefined } : {}),
};
}

View File

@@ -3,30 +3,25 @@
*
* Single segment:
* - If slug matches a subpage → render subpage album grid
* - If slug matches a content page (content/pages/<slug>.md) → render it
* - If slug matches a standalone album → render album detail
*
* Two segments:
* - Treat as subpage-slug/album-slug → render album detail
* with back-link to the subpage
*
* Three or more segments: 404.
*/
import { cookies } from 'next/headers';
import type { Metadata } from 'next';
import { immich, type ImmichAsset } from '@/lib/immich';
import { notFound } from 'next/navigation';
import type { PhotoItem } from './PhotoGrid';
import {
imageUrl,
exifUrl,
videoUrl,
assetPlaceholder,
assetCaption,
assetExifSummary,
downloadUrl,
archiveUrl,
assetAspectRatio,
} from '@/lib/urls';
import { toPhotoItems } from './photoItems';
import { imageUrl, assetPlaceholder, archiveUrl } from '@/lib/urls';
import { ogImageUrl } from '@/lib/ogImage';
import { encodeAssetId, decodeAssetId } from '@/lib/tokens';
import { encodeEmail } from '@/lib/emailObfuscation';
import {
buildCoverGridVars,
getConfig,
@@ -52,6 +47,8 @@ import { mapBlockAssetIds } from '@/lib/journal';
import { strictestPrecision, type LocationPrecision } from '@/lib/mapPrecision';
import { resolveEssayFile, generatedEssayCaption } from '@/lib/essaySource';
import { getServerDictionary } from '@/lib/i18n/server';
import { contentPageMetadata, renderContentPage } from './contentPage';
import { loadAlbumBlocks } from './essayPayload';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
@@ -75,13 +72,32 @@ async function isLocked(key: string, type: 'subpage' | 'album'): Promise<boolean
return !isAuthenticated(key, getCookie, type);
}
/**
* Metadata for a path that names nothing this site publishes. The title is
* the dictionary's, never the requested slug: generateMetadata runs for any
* URL a visitor types, and echoing it would put their text in the `<title>`
* and, through a signed share card, in an image served from this domain.
* Leaving `openGraph` out keeps the root layout's site card. `robots: null`
* drops the layout's `index, follow`, which would otherwise contradict the
* `noindex` Next adds to every not-found response.
*/
function notFoundMetadata(): Metadata {
return { title: getServerDictionary().error.notFoundTitle, robots: null };
}
/** Metadata for a path behind a password this request has not given. */
function lockedMetadata(): Metadata {
return { title: getServerDictionary().password.protectedPage, robots: { index: false } };
}
export async function generateMetadata({ params, searchParams }: PathPageProps): Promise<Metadata> {
// Next hands catch-all segments over percent-encoded, so a non-ASCII slug
// ("/家族相册") would never match a stored one. Decode once, here, and every
// comparison downstream works on the same form (#522).
const { path: rawPath } = await params;
const path = rawPath?.map(normalizeSlug);
if (!path || path.length === 0) return {};
// Deeper paths are a 404 (see the page below); they name nothing to describe.
if (!path || path.length === 0 || path.length > 2) return notFoundMetadata();
// A shared photo link's whole point is that it reaches the server — unlike
// the #photo-N hash it replaces, a `photo` query param is visible here, so
@@ -92,45 +108,51 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
const photoAssetId = photoToken ? decodeAssetId(photoToken) : null;
let photoAsset: ImmichAsset | undefined;
// Each branch follows the page body's order of gates and 404s, and takes
// the title only from site-authored text (an album or subpage name). A
// branch that finds nothing, or finds it locked, returns early, so the
// requested slug never becomes a title or share-card text.
const slug = path[0];
let title = slug;
let title: string;
let subtitle = '';
let description: string | undefined = undefined;
const photoCount = (assets: ImmichAsset[]) =>
getServerDictionary().common.photos(
assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length,
);
if (path.length === 1 && immich.isSubpageSlug(slug)) {
const subpageLocked = await isLocked(slug, 'subpage');
if (await isLocked(slug, 'subpage')) return lockedMetadata();
const result = await immich.getSubpageAlbums(slug);
if (result) {
if (!subpageLocked && result.subpage.subtitle) {
description = result.subpage.subtitle;
}
if (result.albums.length === 1) {
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
if (album && !subpageLocked && !(await isLocked(album.id, 'album'))) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
} else if (!subpageLocked) {
title = result.subpage.title || result.subpage.name;
}
if (!result || result.albums.length === 0) return notFoundMetadata();
if (result.subpage.subtitle) description = result.subpage.subtitle;
if (result.albums.length === 1) {
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
if (!album) return notFoundMetadata();
if (await isLocked(album.id, 'album')) return lockedMetadata();
title = album.albumName;
subtitle = photoCount(album.assets);
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
} else {
title = result.subpage.title || result.subpage.name;
}
} else if (path.length === 2) {
if (await isLocked(slug, 'subpage')) return lockedMetadata();
const album = await immich.getAlbumBySlug(path[1], slug);
if (album && !(await isLocked(slug, 'subpage')) && !(await isLocked(album.id, 'album'))) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
if (!album) return notFoundMetadata();
if (await isLocked(album.id, 'album')) return lockedMetadata();
title = album.albumName;
subtitle = photoCount(album.assets);
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
} else {
const pageMeta = await contentPageMetadata(slug);
if (pageMeta) return pageMeta;
const album = await immich.getAlbumBySlug(slug);
if (album && !(await isLocked(album.id, 'album'))) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
if (!album) return notFoundMetadata();
if (await isLocked(album.id, 'album')) return lockedMetadata();
title = album.albumName;
subtitle = photoCount(album.assets);
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
// The title stays the album's — it's still the context a reader wants —
@@ -140,9 +162,7 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
photoAsset?.exifInfo?.description?.trim() ||
description ||
(subtitle ? `${title} — ${subtitle}` : undefined);
const ogImage = photoAsset
? imageUrl(photoAsset.id, 'preview')
: `/api/og?title=${encodeURIComponent(title)}${subtitle ? `&subtitle=${encodeURIComponent(subtitle)}` : ''}`;
const ogImage = photoAsset ? imageUrl(photoAsset.id, 'preview') : ogImageUrl(title, subtitle);
return {
title,
@@ -161,44 +181,6 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
};
}
/** Map Immich assets to PhotoItem props for the grid/lightbox. */
/**
* `showExif` covers the hover overlay only, and that overlay carries camera,
* lens and focal length — so it follows the `camera` group, not the panel.
*
* `showCaption` follows the `caption` group and decides whether the Immich
* description becomes alt text; see `assetCaption`.
*/
function toPhotoItems(
assets: ImmichAsset[],
showExif: boolean,
showCaption: boolean,
/** The album offering downloads, or undefined when it does not. */
downloadAlbumId?: string,
): PhotoItem[] {
return assets
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
.map((a) => {
const ph = assetPlaceholder(a);
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
const caption = assetCaption(a, showCaption);
const isVideo = a.type === 'VIDEO';
return {
id: encodeAssetId(a.id),
type: isVideo ? 'video' : 'image',
thumbUrl: imageUrl(a.id, 'preview'),
previewUrl: imageUrl(a.id, 'preview'),
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
exifUrl: exifUrl(a.id),
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
...(exif ?? {}),
...(caption ? { caption } : {}),
...(downloadAlbumId ? { downloadUrl: downloadUrl(downloadAlbumId, a.id) } : {}),
aspectRatio: assetAspectRatio(a),
};
});
}
/**
* JSON-LD for an album page. Null unless a site URL is configured — structured
* data is a set of claims about absolute URLs, and there is nothing truthful to
@@ -242,7 +224,8 @@ async function gateIfProtected(
let title = titleOverride || key;
if (!titleOverride && type === 'subpage') {
const subpageData = await immich.getSubpageAlbums(key);
title = subpageData?.subpage.name ?? key;
const sp = subpageData?.subpage;
title = sp ? sp.title || sp.name : key;
}
return <PasswordGate slug={key} title={title} type={type} />;
@@ -280,6 +263,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
const config = getConfig();
// The proofing dialog's "email to photographer" needs a recipient: the
// configured `proofing.email`, else the footer contact email (#736). Encoded
// here so the plain address stays out of the HTML and the RSC payload, like
// every other address the site renders.
const encodedMailto = config.proofing.email ? encodeEmail(config.proofing.email) : undefined;
// Build grid CSS custom properties, optionally merging subpage overrides
const buildGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties => {
const g = { ...config.grid, ...overrides };
@@ -322,7 +311,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
return { ...spGrid, ...albumGrid };
};
if (!path || path.length === 0) {
// One segment or two: nothing on this site lives deeper. A third segment
// used to fall through to the one-segment branch below, which reads only
// `path[0]`, so `/travel/iceland/anything` rendered the Travel subpage.
if (!path || path.length === 0 || path.length > 2) {
notFound();
}
@@ -351,7 +343,9 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
// Look up subpage config for grid overrides and back link
const subpageData = await immich.getSubpageAlbums(subpageSlug, forceFresh);
const spGrid = subpageData?.subpage.grid;
const subpageName = subpageData?.subpage.name ?? subpageSlug;
const subpageName = subpageData
? subpageData.subpage.title || subpageData.subpage.name
: subpageSlug;
const images = toPhotoItems(
album.assets,
@@ -385,6 +379,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
showGear={config.exif.camera}
proofing={proofingFor(subpageData?.subpage)}
allowMailto={config.proofing.allowMailto}
encodedMailto={encodedMailto}
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData}
/>
@@ -435,7 +430,15 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
getCookie: (name) => cookieStore.get(name)?.value,
});
if (file.status === 'locked') {
return <PasswordGate slug={result.subpage.essayFile} title={file.title} type="journal" />;
// The subpage's own title, which the nav already shows — not the
// borrowed entry's, which the journal index hides while it is locked.
return (
<PasswordGate
slug={result.subpage.essayFile}
title={result.subpage.title || result.subpage.name}
type="journal"
/>
);
}
if (file.status === 'open') essayParsed = file.parsed;
}
@@ -453,16 +456,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
// does, from a raw fetch, and add those photos to the page's assets.
// Their ids are encoded here because this path hands EssayView tokens.
if (essayParsed?.blocks.some((b) => b.type === 'album')) {
const byAlbum = new Map<string, typeof allAssets>();
for (const b of essayParsed.blocks) {
if (b.type !== 'album' || !b.albumId || byAlbum.has(b.albumId)) continue;
try {
byAlbum.set(b.albumId, await immich.getAlbumAssetsRaw(b.albumId));
} catch (error) {
// eslint-disable-next-line no-console
console.warn(`[essay] ${slug}: album ${b.albumId} could not be loaded:`, error);
}
}
const byAlbum = await loadAlbumBlocks(essayParsed.blocks, (albumId, error) => {
// eslint-disable-next-line no-console
console.warn(`[essay] ${slug}: album ${albumId} could not be loaded:`, error);
});
const expanded = expandAlbumBlocks(
essayParsed.blocks,
(id) => byAlbum.get(id),
@@ -551,6 +548,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
watermark={config.watermark}
proofing={essayProofing}
allowMailto={config.proofing.allowMailto}
encodedMailto={encodedMailto}
/>
);
}
@@ -599,6 +597,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
showGear={config.exif.camera}
proofing={proofingFor(result.subpage)}
allowMailto={config.proofing.allowMailto}
encodedMailto={encodedMailto}
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData}
/>
@@ -653,12 +652,22 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
gridStyle={buildCoverGridStyle(result.subpage.coverGrid)}
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
{...(nextSubpage
? { nextSubpage: { slug: nextSubpage.slug, name: nextSubpage.name } }
? {
nextSubpage: { slug: nextSubpage.slug, name: nextSubpage.title || nextSubpage.name },
}
: {})}
/>
);
}
// A content page (#722). The admin refuses a page slug that collides with a
// subpage or an album, so the order here only matters for a hand-edited file.
if (path.length === 1) {
const page = await renderContentPage(slug);
if (page === 'not-found') notFound();
if (page) return page;
}
// Otherwise treat as a standalone album slug
const album = await immich.getAlbumBySlug(slug, undefined, forceFresh);
if (!album) {
@@ -701,6 +710,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
showGear={config.exif.camera}
proofing={proofingFor()}
allowMailto={config.proofing.allowMailto}
encodedMailto={encodedMailto}
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData}
/>

View File

@@ -0,0 +1,57 @@
/**
* Immich assets → the PhotoItem props the grid and lightbox render.
*
* Shared by the album routes and the client proofing page, so both render a
* photo exactly the same way.
*/
import type { ImmichAsset } from '@/lib/immich';
import { encodeAssetId } from '@/lib/tokens';
import {
assetAspectRatio,
assetCaption,
assetExifSummary,
assetPlaceholder,
downloadUrl,
exifUrl,
imageUrl,
videoUrl,
} from '@/lib/urls';
import type { PhotoItem } from './PhotoGrid';
/**
* `showExif` covers the hover overlay only, and that overlay carries camera,
* lens and focal length — so it follows the `camera` group, not the panel.
*
* `showCaption` follows the `caption` group and decides whether the Immich
* description becomes alt text; see `assetCaption`.
*/
export function toPhotoItems(
assets: ImmichAsset[],
showExif: boolean,
showCaption: boolean,
/** The album offering downloads, or undefined when it does not. */
downloadAlbumId?: string,
): PhotoItem[] {
return assets
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
.map((a) => {
const ph = assetPlaceholder(a);
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
const caption = assetCaption(a, showCaption);
const isVideo = a.type === 'VIDEO';
return {
id: encodeAssetId(a.id),
type: isVideo ? 'video' : 'image',
thumbUrl: imageUrl(a.id, 'preview'),
previewUrl: imageUrl(a.id, 'preview'),
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
exifUrl: exifUrl(a.id),
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
...(exif ?? {}),
...(caption ? { caption } : {}),
...(downloadAlbumId ? { downloadUrl: downloadUrl(downloadAlbumId, a.id) } : {}),
aspectRatio: assetAspectRatio(a),
};
});
}

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);

View File

@@ -91,6 +91,42 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
{ params: Promise.resolve({ album: 'tok' }) } as never,
),
},
{
name: 'GET /api/download/[album]/[id]',
call: async () =>
(await import('../download/[album]/[id]/route')).GET(
request('/api/download/tok/tok') as never,
{ params: Promise.resolve({ album: 'tok', id: 'tok' }) } as never,
),
},
// Client proofing links: the site password applies to clients as well.
{
name: 'PUT /api/proof/[token]/selection',
call: async () =>
(await import('../proof/[token]/selection/route')).PUT(
new NextRequest('http://localhost/api/proof/tok/selection', {
method: 'PUT',
body: '{"selection":[]}',
}) as never,
{ params: Promise.resolve({ token: 'tok' }) } as never,
),
},
{
name: 'POST /api/proof/[token]/submit',
call: async () =>
(await import('../proof/[token]/submit/route')).POST(
new NextRequest('http://localhost/api/proof/tok/submit', { method: 'POST' }) as never,
{ params: Promise.resolve({ token: 'tok' }) } as never,
),
},
{
name: 'GET /api/proof/[token]/archive',
call: async () =>
(await import('../proof/[token]/archive/route')).GET(
request('/api/proof/tok/archive') as never,
{ params: Promise.resolve({ token: 'tok' }) } as never,
),
},
{
// Stores whatever a visitor sends; a locked site accepts nothing from strangers.
name: 'POST /api/contact',
@@ -101,6 +137,68 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
},
];
/**
* Routes that answer a locked site on purpose, keyed like the GATED names
* (path under /api). Everything else under app/api must be in GATED.
* /api/admin is absent: it has its own password and its own suite
* (app/api/admin/__tests__/admin-guards.test.ts).
*/
const OPEN: Record<string, string> = {
health: 'a container health probe runs without cookies',
auth: 'the login endpoint cannot sit behind the lock it opens',
'fonts/css': 'the gate page is set in the theme fonts',
'fonts/file/[name]': 'the gate page is set in the theme fonts',
favicon: 'the gate page shows the site icon',
install: 'the first-run wizard; setup-token gated, refuses once installed',
'install/albums': 'the first-run wizard; setup-token gated, refuses once installed',
webhook: 'server-to-server from Immich, HMAC-verified',
'analytics/track': 'counts a view of the gate itself; returns no content',
};
/** `GET /api/download/[album]/[id]` → `download/[album]/[id]`. */
const routeDir = (name: string) => name.replace(/^[A-Z]+ \/api\//, '');
describe('site gate coverage', () => {
it('has a GATED or OPEN row for every public route module under app/api', async () => {
const fs = await import('fs');
const path = await import('path');
const apiDir = path.join(process.cwd(), 'app/api');
const found: string[] = [];
const walk = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === '__tests__') continue;
walk(full);
} else if (/^route\.tsx?$/.test(entry.name)) {
found.push(path.relative(apiDir, path.dirname(full)).replace(/\\/g, '/'));
}
}
};
walk(apiDir);
const covered = new Set([...GATED.map((r) => routeDir(r.name)), ...Object.keys(OPEN)]);
const uncovered = found.filter((p) => !p.startsWith('admin/') && !covered.has(p));
expect(uncovered, `Public API routes with no site-gate row: ${uncovered.join(', ')}`).toEqual(
[],
);
});
it('lists no route in OPEN that does not exist', async () => {
const fs = await import('fs');
const path = await import('path');
const missing = Object.keys(OPEN).filter((dir) => {
const base = path.join(process.cwd(), 'app/api', dir);
return (
!fs.existsSync(path.join(base, 'route.ts')) && !fs.existsSync(path.join(base, 'route.tsx'))
);
});
expect(missing).toEqual([]);
});
});
describe('public content routes behind a locked site', () => {
beforeEach(() => {
config.sitePassword = 'letmein';

View File

@@ -248,6 +248,106 @@ const ROUTES: {
{ params: Promise.resolve({ slug: 'test-slug' }) },
],
},
{
name: 'GET /api/admin/pages',
path: 'pages',
load: () => import('../pages/route'),
method: 'GET',
args: () => [],
},
{
name: 'POST /api/admin/pages',
path: 'pages',
load: () => import('../pages/route'),
method: 'POST',
args: () => [new Request('http://localhost/api/admin/pages', { method: 'POST', body: '{}' })],
},
{
name: 'GET /api/admin/pages/[slug]',
path: 'pages/[slug]',
load: () => import('../pages/[slug]/route'),
method: 'GET',
args: () => [
new Request('http://localhost/api/admin/pages/test-slug'),
{ params: Promise.resolve({ slug: 'test-slug' }) },
],
},
{
name: 'PUT /api/admin/pages/[slug]',
path: 'pages/[slug]',
load: () => import('../pages/[slug]/route'),
method: 'PUT',
args: () => [
new Request('http://localhost/api/admin/pages/test-slug', { method: 'PUT', body: '{}' }),
{ params: Promise.resolve({ slug: 'test-slug' }) },
],
},
{
name: 'DELETE /api/admin/pages/[slug]',
path: 'pages/[slug]',
load: () => import('../pages/[slug]/route'),
method: 'DELETE',
args: () => [
new Request('http://localhost/api/admin/pages/test-slug', { method: 'DELETE' }),
{ params: Promise.resolve({ slug: 'test-slug' }) },
],
},
{
name: 'GET /api/admin/proofing',
path: 'proofing',
load: () => import('../proofing/route'),
method: 'GET',
args: () => [new Request('http://localhost/api/admin/proofing')],
},
{
name: 'POST /api/admin/proofing',
path: 'proofing',
load: () => import('../proofing/route'),
method: 'POST',
args: () => [
new Request('http://localhost/api/admin/proofing', { method: 'POST', body: '{}' }),
],
},
{
name: 'GET /api/admin/proofing/[id]',
path: 'proofing/[id]',
load: () => import('../proofing/[id]/route'),
method: 'GET',
args: () => [
new Request('http://localhost/api/admin/proofing/abc'),
{ params: Promise.resolve({ id: 'abc' }) },
],
},
{
name: 'PATCH /api/admin/proofing/[id]',
path: 'proofing/[id]',
load: () => import('../proofing/[id]/route'),
method: 'PATCH',
args: () => [
new Request('http://localhost/api/admin/proofing/abc', { method: 'PATCH', body: '{}' }),
{ params: Promise.resolve({ id: 'abc' }) },
],
},
{
name: 'DELETE /api/admin/proofing/[id]',
path: 'proofing/[id]',
load: () => import('../proofing/[id]/route'),
method: 'DELETE',
args: () => [
new Request('http://localhost/api/admin/proofing/abc', { method: 'DELETE' }),
{ params: Promise.resolve({ id: 'abc' }) },
],
},
{
name: 'GET /api/admin/proofing/[id]/export',
path: 'proofing/[id]/export',
load: () => import('../proofing/[id]/export/route'),
method: 'GET',
args: () => [
new Request('http://localhost/api/admin/proofing/abc/export'),
{ params: Promise.resolve({ id: 'abc' }) },
],
},
];
describe('admin route guards', () => {

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: () => true,
isAdminAuthenticated: async () => true,
COOKIE_NAME: 'folio_admin_session',
}));
vi.mock('@/lib/config', () => ({
getConfig: vi.fn(() => ({
needsCredentials: false,
immich: { apiUrl: 'http://immich.test/api', apiKey: 'key' },
})),
}));
import { GET } from '../assets/route';
type Item = { id: string; originalFileName: string; fileCreatedAt: string; isFavorite: boolean };
const item = (id: string): Item => ({
id,
originalFileName: `${id}.jpg`,
fileCreatedAt: '2024-05-17T10:00:00.000Z',
isFavorite: false,
});
let fetchMock: ReturnType<typeof vi.fn>;
const bodies = () =>
fetchMock.mock.calls.map(([, init]) => JSON.parse((init as RequestInit).body as string));
function respond(...pages: Array<{ items: Item[]; nextPage: string | null }>) {
for (const p of pages) {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ assets: p }), { status: 200 }));
}
}
async function get(query: string) {
const res = await GET(new NextRequest(`http://localhost/api/admin/assets?${query}`));
return { status: res.status, body: await res.json() };
}
describe('GET /api/admin/assets — search (#602)', () => {
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => vi.unstubAllGlobals());
it('browses without a query in one Immich call', async () => {
respond({ items: [item('a')], nextPage: '2' });
const { status, body } = await get('page=1&favorites=true');
expect(status).toBe(200);
expect(body).toEqual({ assets: [item('a')], nextPage: 2 });
expect(bodies()).toEqual([expect.objectContaining({ page: 1, isFavorite: true })]);
expect(bodies()[0]).not.toHaveProperty('originalFileName');
});
it('searches filename and description and merges without duplicates', async () => {
respond(
{ items: [item('a'), item('b')], nextPage: null },
{ items: [item('b'), item('c')], nextPage: '2' },
);
const { body } = await get('page=1&q=%20harbour%20');
expect(bodies()).toEqual([
expect.objectContaining({ originalFileName: 'harbour' }),
expect.objectContaining({ description: 'harbour' }),
]);
expect(body.assets.map((a: Item) => a.id)).toEqual(['a', 'b', 'c']);
expect(body.nextPage).toBe(2);
});
it.each([
['2024', '2024-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z'],
['2024-02', '2024-02-01T00:00:00.000Z', '2024-03-01T00:00:00.000Z'],
['2024-02-29', '2024-02-29T00:00:00.000Z', '2024-03-01T00:00:00.000Z'],
])('treats %s as a taken-date range', async (q, after, before) => {
respond({ items: [], nextPage: null });
await get(`q=${q}`);
expect(bodies()).toEqual([expect.objectContaining({ takenAfter: after, takenBefore: before })]);
});
it('falls back to text search for an impossible date', async () => {
respond({ items: [], nextPage: null }, { items: [], nextPage: null });
await get('q=2023-02-29');
expect(bodies()[0]).toMatchObject({ originalFileName: '2023-02-29' });
});
it.each(['0', '-1', 'abc', '1.5', '99999', '1e3'])('rejects page=%s', async (page) => {
const { status } = await get(`page=${page}`);
expect(status).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects an overlong query before calling Immich', async () => {
const { status } = await get(`q=${'x'.repeat(101)}`);
expect(status).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
});
it('maps an Immich error to 502', async () => {
fetchMock.mockResolvedValue(new Response('nope', { status: 500 }));
const { status } = await get('q=x');
expect(status).toBe(502);
});
});

View File

@@ -0,0 +1,236 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises';
/**
* Every content editor's route (#601): GET hands out a version, a PUT with a
* stale `If-Match` is answered 409 with the current version and writes
* nothing, a matching one saves and returns the new version, and a PUT with no
* `If-Match` at all — an admin tab from before the upgrade — still saves.
*
* Real services on a temp content/ directory; the routes resolve it from
* process.cwd() at import, so they are imported after cwd is pointed there.
*/
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: vi.fn(() => true),
isAdminAuthenticated: vi.fn(async () => true),
COOKIE_NAME: 'folio_admin_session',
}));
vi.mock('@/lib/config', () => ({
invalidateConfigCache: vi.fn(),
getConfigOrNull: vi.fn(() => null),
getConfig: vi.fn(() => ({
privacy: { enabled: true },
subpages: [],
albumPasswords: {},
lang: 'en',
})),
deriveGallery: vi.fn(() => ({ subpages: [] })),
isPageRef: (e: unknown) => !!e && typeof e === 'object' && 'page' in e,
}));
vi.mock('@/lib/privacy', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/privacy')>()),
processingFacts: vi.fn(() => []),
starterHeadings: vi.fn(() => ''),
}));
vi.mock('@/lib/admin/pageSlugs', () => ({
takenPageSlugs: vi.fn(async () => ({ subpages: [], albums: [], journal: [] })),
}));
vi.mock('@/lib/immich', () => ({ immich: { invalidateAll: vi.fn() } }));
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
let root: string;
let content: string;
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-conflict-'));
content = path.join(root, 'content');
await mkdir(path.join(content, 'journal'), { recursive: true });
await mkdir(path.join(content, 'pages'), { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.resetModules();
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
const req = (method: string, body?: unknown, ifMatch?: string) =>
new Request('http://localhost/api/admin/x', {
method,
headers: {
'Content-Type': 'application/json',
...(ifMatch === undefined ? {} : { 'If-Match': ifMatch }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
type Handler = (request: Request, ctx?: never) => Promise<Response>;
interface Case {
name: string;
file: string;
initial: string;
load: () => Promise<{ GET: Handler; PUT: Handler }>;
ctx?: unknown;
body: (tag: string) => unknown;
}
const md = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`;
const slugCtx = (slug: string) => ({ params: Promise.resolve({ slug }) });
const cases: Case[] = [
{
name: 'settings',
file: 'settings.yaml',
initial: 'title: Start\n',
load: () => import('../settings/route'),
body: (tag) => ({ settings: { title: tag } }),
},
{
name: 'gallery',
file: 'gallery.yaml',
initial: 'hero: []\nalbums: []\nsubpages: []\n',
load: () => import('../gallery/route'),
body: (tag) => ({ gallery: { hero: [], albums: [tag], subpages: [] } }),
},
{
name: 'about',
file: 'about.md',
initial: '---\nname: Start\n---\n\nHello\n',
load: () => import('../about/route'),
body: (tag) => ({ meta: { name: tag }, body: 'Hello' }),
},
{
name: 'privacy',
file: 'privacy.md',
initial: 'Start\n',
load: () => import('../privacy/route'),
body: (tag) => ({ body: tag }),
},
{
name: 'journal entry',
file: 'journal/trip.md',
initial: md('Start'),
load: () => import('../journal/[slug]/route') as never,
ctx: slugCtx('trip'),
body: (tag) => ({ rawMarkdown: md(tag) }),
},
{
name: 'content page',
file: 'pages/faq.md',
initial: md('Start'),
load: () => import('../pages/[slug]/route') as never,
ctx: slugCtx('faq'),
body: (tag) => ({ rawMarkdown: md(tag) }),
},
];
describe.each(cases)('$name route (#601)', ({ file, initial, load, ctx, body }) => {
const call = (h: Handler, r: Request) => h(r, ctx as never);
it('GET returns a version and ETag; a matching If-Match saves and returns the next one', async () => {
await writeFile(path.join(content, file), initial);
const { GET, PUT } = await load();
const got = await call(GET, req('GET'));
const { version } = await got.json();
expect(typeof version).toBe('string');
expect(got.headers.get('ETag')).toBe(`"${version}"`);
const saved = await call(PUT, req('PUT', body('Mine'), `"${version}"`));
expect(saved.status).toBe(200);
const data = await saved.json();
expect(data.version).toMatch(/^[0-9a-f]{32}$/);
expect(data.version).not.toBe(version);
});
it('a stale If-Match gets 409 with the current version and writes nothing', async () => {
await writeFile(path.join(content, file), initial);
const { GET, PUT } = await load();
const { version: stale } = await (await call(GET, req('GET'))).json();
// Another tab saves in between.
const other = await call(PUT, req('PUT', body('Theirs'), `"${stale}"`));
expect(other.status).toBe(200);
const { version: current } = await other.json();
const onDisk = await readFile(path.join(content, file), 'utf8');
const res = await call(PUT, req('PUT', body('Mine'), `"${stale}"`));
expect(res.status).toBe(409);
const data = await res.json();
expect(data).toMatchObject({ conflict: true, version: current });
expect(res.headers.get('ETag')).toBe(`"${current}"`);
await expect(readFile(path.join(content, file), 'utf8')).resolves.toBe(onDisk);
// "Overwrite anyway" resends with the version the 409 named.
const forced = await call(PUT, req('PUT', body('Mine'), `"${current}"`));
expect(forced.status).toBe(200);
});
it('a file that is not valid UTF-8 saves with the version GET handed out', async () => {
// A hand edit in Latin-1: "Über" as 0xDC, which UTF-8 cannot decode. The
// decoded text re-encodes to other bytes, so a version taken from it never
// matched the save's check and every save was a 409.
const latin1 = Buffer.concat([
Buffer.from(initial),
Buffer.from('\n# '),
Buffer.from([0xdc]),
Buffer.from('ber\n'),
]);
await writeFile(path.join(content, file), latin1);
const { GET, PUT } = await load();
const { version } = await (await call(GET, req('GET'))).json();
const res = await call(PUT, req('PUT', body('Mine'), `"${version}"`));
expect(res.status).toBe(200);
});
it('a PUT without If-Match still saves (older clients)', async () => {
await writeFile(path.join(content, file), initial);
const { PUT } = await load();
const res = await call(PUT, req('PUT', body('Mine')));
expect(res.status).toBe(200);
await expect(readFile(path.join(content, file), 'utf8')).resolves.not.toBe(initial);
});
});
describe('renames (#601)', () => {
it('a journal rename is checked against the entry it was loaded from', async () => {
await writeFile(path.join(content, 'journal/trip.md'), md('Start'));
const { GET, PUT } = await import('../journal/[slug]/route');
const ctx = slugCtx('trip');
const { version } = await (await GET(req('GET'), ctx)).json();
await writeFile(path.join(content, 'journal/trip.md'), md('Hand edit'));
const res = await PUT(req('PUT', { rawMarkdown: md('Mine'), newSlug: 'voyage' }, version), ctx);
expect(res.status).toBe(409);
await expect(readFile(path.join(content, 'journal/trip.md'), 'utf8')).resolves.toBe(
md('Hand edit'),
);
await expect(readFile(path.join(content, 'journal/voyage.md'))).rejects.toThrow();
});
it('a page rename reports the gallery.yaml version change it made', async () => {
await writeFile(path.join(content, 'pages/faq.md'), md('FAQ'));
await writeFile(path.join(content, 'gallery.yaml'), 'subpages:\n - page: faq\n');
const gallery = await import('../gallery/route');
const { version: galleryBefore } = await (await gallery.GET()).json();
const { GET, PUT } = await import('../pages/[slug]/route');
const ctx = slugCtx('faq');
const { version } = await (await GET(req('GET'), ctx)).json();
const res = await PUT(req('PUT', { rawMarkdown: md('FAQ'), newSlug: 'help' }, version), ctx);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.menuRenamed).toBe(true);
expect(data.galleryVersion.from).toBe(galleryBefore);
const { version: galleryAfter } = await (await gallery.GET()).json();
expect(data.galleryVersion.to).toBe(galleryAfter);
});
});

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises';
/**
* A page rename or delete moves the file first and then follows up the menu
* reference in gallery.yaml (#722). When that follow-up fails — gallery.yaml
* edited by hand into invalid YAML, a write error — the page has already been
* renamed or deleted. Answering 500 then told the admin nothing was saved: the
* panel kept the old slug, and every retry hit a 404 for a file that was gone.
*
* Real services on a temp content/ directory, as in concurrent-edits.test.ts.
*/
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: vi.fn(() => true),
isAdminAuthenticated: vi.fn(async () => true),
COOKIE_NAME: 'folio_admin_session',
}));
vi.mock('@/lib/config', () => ({
invalidateConfigCache: vi.fn(),
isPageRef: (e: unknown) => !!e && typeof e === 'object' && 'page' in e,
}));
vi.mock('@/lib/admin/pageSlugs', () => ({
takenPageSlugs: vi.fn(async () => ({ subpages: [], albums: [], journal: [] })),
}));
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
let root: string;
let content: string;
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-page-refs-'));
content = path.join(root, 'content');
await mkdir(path.join(content, 'pages'), { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.resetModules();
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
const req = (method: string, body?: unknown) =>
new Request('http://localhost/api/admin/pages/x', {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const md = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`;
const slugCtx = (slug: string) => ({ params: Promise.resolve({ slug }) });
const BROKEN_YAML = 'subpages:\n - page: faq\n bad: [unclosed\n';
describe('page rename when the menu cannot be updated', () => {
it('reports the rename as done, with a warning about the menu', async () => {
await writeFile(path.join(content, 'pages/faq.md'), md('FAQ'));
await writeFile(path.join(content, 'gallery.yaml'), BROKEN_YAML);
const { PUT } = await import('../pages/[slug]/route');
const res = await PUT(req('PUT', { rawMarkdown: md('FAQ'), newSlug: 'help' }), slugCtx('faq'));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
expect(data.page.slug).toBe('help');
expect(data.menuRenamed).toBe(false);
expect(data.galleryVersion).toBeNull();
expect(data.warning).toMatch(/gallery\.yaml/);
// The rename itself happened, and gallery.yaml was left alone.
await expect(readFile(path.join(content, 'pages/help.md'), 'utf8')).resolves.toBe(md('FAQ'));
await expect(readFile(path.join(content, 'pages/faq.md'))).rejects.toThrow();
await expect(readFile(path.join(content, 'gallery.yaml'), 'utf8')).resolves.toBe(BROKEN_YAML);
});
it('a plain save does not touch the menu and carries no warning', async () => {
await writeFile(path.join(content, 'pages/faq.md'), md('FAQ'));
await writeFile(path.join(content, 'gallery.yaml'), BROKEN_YAML);
const { PUT } = await import('../pages/[slug]/route');
const res = await PUT(req('PUT', { rawMarkdown: md('FAQ 2') }), slugCtx('faq'));
expect(res.status).toBe(200);
expect((await res.json()).warning).toBeUndefined();
});
});
describe('page delete when the menu cannot be updated', () => {
it('reports the delete as done, with a warning about the menu', async () => {
await writeFile(path.join(content, 'pages/faq.md'), md('FAQ'));
await writeFile(path.join(content, 'gallery.yaml'), BROKEN_YAML);
const { DELETE } = await import('../pages/[slug]/route');
const res = await DELETE(req('DELETE'), slugCtx('faq'));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
expect(data.deletedSlug).toBe('faq');
expect(data.removedFromMenu).toBe(false);
expect(data.galleryVersion).toBeNull();
expect(data.warning).toMatch(/gallery\.yaml/);
await expect(readFile(path.join(content, 'pages/faq.md'))).rejects.toThrow();
});
});

Some files were not shown because too many files have changed in this diff Show More