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:
@@ -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} />}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
85
app/[...path]/__tests__/lightbox-filtered-unheart.test.tsx
Normal file
85
app/[...path]/__tests__/lightbox-filtered-unheart.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
61
app/[...path]/__tests__/loadAlbumBlocks.test.ts
Normal file
61
app/[...path]/__tests__/loadAlbumBlocks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
161
app/[...path]/__tests__/metadata-title.test.ts
Normal file
161
app/[...path]/__tests__/metadata-title.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
78
app/[...path]/__tests__/path-depth.test.ts
Normal file
78
app/[...path]/__tests__/path-depth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
121
app/[...path]/__tests__/proofing-count.test.tsx
Normal file
121
app/[...path]/__tests__/proofing-count.test.tsx
Normal 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\n');
|
||||
render(<EssayView essay={essay} assets={ASSETS} title="Story" proofing />);
|
||||
expect(await screen.findByText('❤️ 1 Selected')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
77
app/[...path]/__tests__/proofing-filter-empty.test.tsx
Normal file
77
app/[...path]/__tests__/proofing-filter-empty.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
126
app/[...path]/__tests__/proofing-lazy.test.tsx
Normal file
126
app/[...path]/__tests__/proofing-lazy.test.tsx
Normal 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\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');
|
||||
});
|
||||
});
|
||||
31
app/[...path]/__tests__/proofing-mailto-wiring.test.ts
Normal file
31
app/[...path]/__tests__/proofing-mailto-wiring.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
108
app/[...path]/contentPage.tsx
Normal file
108
app/[...path]/contentPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
app/[...path]/essayPayload.ts
Normal file
197
app/[...path]/essayPayload.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
57
app/[...path]/photoItems.ts
Normal file
57
app/[...path]/photoItems.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
58
app/admin/__tests__/AdminOverview.test.tsx
Normal file
58
app/admin/__tests__/AdminOverview.test.tsx
Normal 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' });
|
||||
});
|
||||
});
|
||||
76
app/admin/__tests__/AssetPicker.test.tsx
Normal file
76
app/admin/__tests__/AssetPicker.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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]));
|
||||
});
|
||||
});
|
||||
|
||||
57
app/admin/__tests__/NewPageDialog.test.tsx
Normal file
57
app/admin/__tests__/NewPageDialog.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
35
app/admin/__tests__/analyticsDays.test.ts
Normal file
35
app/admin/__tests__/analyticsDays.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
24
app/admin/__tests__/page-link-slug.test.ts
Normal file
24
app/admin/__tests__/page-link-slug.test.ts
Normal 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}`}');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -34,4 +34,5 @@
|
||||
@import './styles/diagnostics.css';
|
||||
@import './styles/messages.css';
|
||||
@import './styles/notifications.css';
|
||||
@import './styles/proofing.css';
|
||||
@import './styles/redesign.css';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'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)"
|
||||
/>
|
||||
|
||||
63
app/admin/components/InlineMarkdownField.tsx
Normal file
63
app/admin/components/InlineMarkdownField.tsx
Normal 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)} />;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
468
app/admin/components/ProofingView.tsx
Normal file
468
app/admin/components/ProofingView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
91
app/admin/components/__tests__/PrivacyEditor-draft.test.tsx
Normal file
91
app/admin/components/__tests__/PrivacyEditor-draft.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
176
app/admin/components/__tests__/admin-dialogs-a11y.test.tsx
Normal file
176
app/admin/components/__tests__/admin-dialogs-a11y.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
266
app/admin/components/__tests__/inflight-save-edits.test.tsx
Normal file
266
app/admin/components/__tests__/inflight-save-edits.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
196
app/admin/components/__tests__/inline-markdown-fields.test.tsx
Normal file
196
app/admin/components/__tests__/inline-markdown-fields.test.tsx
Normal 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',
|
||||
'',
|
||||
``,
|
||||
'',
|
||||
'::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 & b <c>');
|
||||
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).',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
24
app/admin/components/analyticsDays.ts
Normal file
24
app/admin/components/analyticsDays.ts
Normal 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;
|
||||
}
|
||||
20
app/admin/components/fields/EnvLockNote.tsx
Normal file
20
app/admin/components/fields/EnvLockNote.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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) => {
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
82
app/admin/components/journal/PageSettingsPanel.tsx
Normal file
82
app/admin/components/journal/PageSettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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 & 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' }}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
134
app/admin/components/page-builder/NewPageDialog.tsx
Normal file
134
app/admin/components/page-builder/NewPageDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
app/admin/components/page-builder/PageContentEditor.tsx
Normal file
19
app/admin/components/page-builder/PageContentEditor.tsx
Normal 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)}`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
394
app/admin/components/page-builder/PagePanel.tsx
Normal file
394
app/admin/components/page-builder/PagePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 “Email to photographer” sends a selection. Falls back to the footer
|
||||
contact email; when neither is set, the button is hidden.
|
||||
</p>
|
||||
</div>
|
||||
</FeatureGroup>
|
||||
|
||||
<FeatureGroup
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
20
app/admin/components/useLatest.ts
Normal file
20
app/admin/components/useLatest.ts
Normal 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;
|
||||
}
|
||||
97
app/admin/components/useVersionedSave.ts
Normal file
97
app/admin/components/useVersionedSave.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
11
app/admin/pages/edit/[slug]/page.tsx
Normal file
11
app/admin/pages/edit/[slug]/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
|
||||
5
app/admin/proofing/page.tsx
Normal file
5
app/admin/proofing/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ProofingView from '../components/ProofingView';
|
||||
|
||||
export default function AdminProofingPage() {
|
||||
return <ProofingView />;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
92
app/admin/styles/proofing.css
Normal file
92
app/admin/styles/proofing.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
105
app/api/admin/__tests__/assets-search.test.ts
Normal file
105
app/api/admin/__tests__/assets-search.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
236
app/api/admin/__tests__/concurrent-edits.test.ts
Normal file
236
app/api/admin/__tests__/concurrent-edits.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
108
app/api/admin/__tests__/page-menu-refs.test.ts
Normal file
108
app/api/admin/__tests__/page-menu-refs.test.ts
Normal 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
Reference in New Issue
Block a user