diff --git a/.env.local.example b/.env.local.example
index ef7f6b3..2d7160f 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -68,6 +68,12 @@ AUTH_SECRET=change-me-to-a-long-random-string
# Leave unset or empty to disable the admin UI entirely.
# ADMIN_PASSWORD=your-secure-admin-password
+# ── Development over the LAN (next dev only) ──────────────────────
+# `next dev` refuses its own assets to any hostname but localhost, so the
+# page never hydrates when opened as http://192.168.1.20:3000. List the
+# hostnames or IPs you open it from, comma-separated. No effect in production.
+# ALLOWED_DEV_ORIGINS=192.168.1.20,myhost.local
+
# ── Site URL ─────────────────────────────────────────────────────
# Used for sitemap.xml, the feed and structured data when `url:` is not
# set in settings.yaml. The settings file takes precedence, so the admin
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d111a4b..afeb9bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,119 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Releases up to and including v0.9.2 are documented in the
[GitHub releases](https://github.com/ralksta/immich-folio/releases).
+## [0.17.0] — 2026-09-23
+
+### Added
+
+- **Download an album, or a proofing selection, as a ZIP of the originals**
+ ([#561](https://github.com/ralksta/immich-folio/pull/561),
+ [#665](https://github.com/ralksta/immich-folio/pull/665)) — contributed by
+ [@lancetm714](https://github.com/lancetm714). An album with
+ `download: true` — now also a switch in the admin album editor, _Allow
+ original downloads_ — shows a _Download album_ link in its header, and the
+ proofing dialog gains _Download selected (.zip)_. The archive streams one
+ original at a time, uncompressed, so memory stays flat however large the
+ album is, and a cancelled download stops pulling from Immich. The route
+ re-checks everything the single-file download does — the allowlist, the
+ `download` opt-in, every password gate on the way to the album — and a
+ selection may name only assets of that album, each once, at most 1,000.
+ Downloads are limited to five a minute per visitor; a refusal answers a
+ browser with a short page in the site's language that links back to the
+ album, instead of raw JSON.
+
+- **Journal entries can start from a template**
+ ([#662](https://github.com/ralksta/immich-folio/pull/662)). Besides a blank
+ entry, the _New Journal Entry_ dialog offers eight structures: Wedding,
+ Hiking / Outdoor, Travel, Birthday / Family, Portrait Session, Behind the
+ Scenes, Pets and New Series.
+
+- **Four new journal blocks, and drag-and-drop ordering**
+ ([#663](https://github.com/ralksta/immich-folio/pull/663)):
+ - **Photo grid** — three or more photos in rows of three, at their real
+ proportions. A third photo used to be dropped silently on the next save.
+ - **Facts** — label/value pairs, rendered as a definition list.
+ - **Map** — the pins the author lists: typed coordinates, a photo placed by
+ its GPS, or every geotagged photo of the entry, optionally joined by a
+ line. Photo pins follow the album's location precision, and `exact` is
+ rounded to a 1 km grid. Only the pins reach the browser, never asset ids.
+ - **Album** — a slice of an album (`count`, `skip`, laid out as a grid, in
+ pairs or one per row), expanded into ordinary photos before rendering.
+
+ The Journal Studio and the page builder's essay editor both get the new
+ blocks, and every block card can be dragged by its grip; the arrow buttons
+ stay for the keyboard. The templates use the new blocks.
+
+### Fixed
+
+- **Admin login and gallery passwords work over plain HTTP**
+ ([#666](https://github.com/ralksta/immich-folio/pull/666), reported in
+ [#664](https://github.com/ralksta/immich-folio/issues/664) by
+ [@majandres](https://github.com/majandres)). Login cookies were marked
+ `Secure` whenever the app ran in production mode, which the Docker image
+ always does, and a browser silently drops a `Secure` cookie set over
+ `http://`. An instance opened at `http://host:7211` accepted the password
+ and answered every following request with 401. The flag now follows the
+ request: HTTPS, or `X-Forwarded-Proto: https` from a reverse proxy.
+
+- **Unsaved edits in the page builder and the journal editor survive leaving
+ the page** ([#668](https://github.com/ralksta/immich-folio/pull/668)).
+ Switching admin tabs, pressing back, reloading or logging out used to
+ discard them without a word. Each editor now keeps a draft for the browser
+ tab and restores it on return, with a notice and a _Discard changes_
+ button. If the file was saved elsewhere in the meantime, the draft is held
+ back and the editor asks before restoring it over the newer version.
+
+- **Favourites are kept per album**
+ ([#561](https://github.com/ralksta/immich-folio/pull/561)). Every album
+ shared one selection, so hearts set in one album showed up in the next.
+ See the upgrade notes.
+
+- **The proofing dialog's copy buttons work without a clipboard**
+ ([#672](https://github.com/ralksta/immich-folio/pull/672)). Browsers offer
+ the clipboard only on HTTPS, so on a site reached over plain HTTP _Copy
+ shareable link_ and _Copy text list_ did nothing. They now show the link or
+ the list, selected, in a field to copy by hand — as the image viewer
+ already did for its photo link.
+
+- **English leftovers on translated sites**
+ ([#673](https://github.com/ralksta/immich-folio/pull/673),
+ [#674](https://github.com/ralksta/immich-folio/pull/674)): the back link of
+ an album inside a subpage read "Back to …", and screen readers heard
+ English labels on photo tiles and footer links.
+
+### Internal
+
+- **The journal editor is split up**
+ ([#669](https://github.com/ralksta/immich-folio/pull/669),
+ [#670](https://github.com/ralksta/immich-folio/pull/670)). The 2,000-line
+ `JournalStudio.tsx` is now the entry list and an editor of about 600
+ lines, with the block forms, story settings, live preview, split pane and
+ block operations in files of their own and the extracted logic under test.
+ Screenshots before and after are pixel-identical.
+- **`next dev` over the LAN**
+ ([#671](https://github.com/ralksta/immich-folio/pull/671)): the hostnames
+ allowed to load dev assets come from `ALLOWED_DEV_ORIGINS` in `.env.local`
+ instead of the config file.
+- Prettier 3.9.8 ([#625](https://github.com/ralksta/immich-folio/pull/625),
+ [#667](https://github.com/ralksta/immich-folio/pull/667)).
+
+### Upgrade notes
+
+**Visitors' favourites from before this release are not carried over.** They
+were stored under one key shared by every album; each album now has its own,
+and the old key is no longer read. A selection in progress has to be made
+again. A shared proofing link still restores the selection it carries.
+
+**Behind a reverse proxy, make sure it sends `X-Forwarded-Proto`**, so login
+cookies stay `Secure` on HTTPS. Caddy and Traefik send it on their own; nginx
+needs `proxy_set_header X-Forwarded-Proto $scheme;`, as in the documented
+config. Plain-HTTP setups need nothing — logins simply work now.
+
+**Running from source:** run `npm install` after pulling — the ZIP download
+adds the `archiver` dependency. Docker images include it.
+
+No `IMAGE_CACHE_VERSION` bump is needed this time.
+
## [0.16.0] — 2026-09-22
### Security
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5f0052a..6db8697 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -72,7 +72,7 @@ See `app/admin/__tests__/SaveBar.test.tsx` for a worked example.
**Prefer extracting the logic over rendering it.** A pure function moved into
`lib/` — or into a sibling module, as `page-builder/albumEntries.ts` was — is
cheaper to test and stays tested when the markup around it changes. Reach for a
-component test when the behaviour *is* the rendering: a state that hides a form,
+component test when the behaviour _is_ the rendering: a state that hides a form,
a control that must stay disabled, a keyboard shortcut.
## Code style
diff --git a/README.md b/README.md
index 4c49ab9..a53bb02 100644
--- a/README.md
+++ b/README.md
@@ -60,11 +60,12 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **
- **YAML gallery config** — all gallery structure defined in a single `content/gallery.yaml` file
- **Markdown about page** — `content/about.md` with frontmatter for portrait, name, location, and gear list, editable from the admin panel
- **Journal** — photo essays and travel stories at `/journal`, with drafts, per-entry passwords, cover images and reading times
-- **Photo Essay mode** — long-form storytelling pages alternating text with fullbleed and paired image layouts
+- **Photo Essay mode** — long-form storytelling pages alternating text with fullbleed, paired and grid image layouts, facts lists and a map of the entry's geotagged photos
- **Unlisted subpages** _(experimental)_ — reachable by direct link, absent from the navigation
- **Subpage on/off toggle** — take a page offline without deleting it
- **External navigation links** _(experimental)_ — point the header at a shop, a blog, or a social profile
- **Client proofing** — clients favorite photos and export the selection; picks are encoded in the URL, nothing is stored server-side
+- **Originals delivery** — per-album opt-in lets visitors download a whole album or their proofing selection as a ZIP of the originals
- **Lightbox watermark** — configurable overlay on fullscreen images
- **Privacy-friendly analytics** — cookieless view counts, no third parties, can be switched off
- **Dynamic OG images** — auto-generated social preview images per album
@@ -244,7 +245,7 @@ All gallery structure — hero images, albums, subpages, grid layout, footer —
### Journal & Photo Essays
-Long-form storytelling with fullbleed photos, side-by-side pairs, quotes and captions — as a standalone `/journal` section, or as a single essay on one subpage.
+Long-form storytelling with fullbleed photos, side-by-side pairs and grids, quotes, facts lists, a map of the entry's own geotagged photos and captions — as a standalone `/journal` section, or as a single essay on one subpage. New entries can start from a template.
→ **[Journal & Photo Essays Guide](docs/journal.md)**
@@ -369,7 +370,10 @@ easier to live with.
in the browser tab, and made a gallery with no albums yet a perfectly valid
starting point rather than an error. In v0.15.0, made client proofing work
properly: the selection bar floats again with page transitions on, and it is
- readable in light mode. Also brought links to journal quote attributions.
+ readable in light mode. Also brought links to journal quote attributions. In
+ v0.17.0, built the ZIP download: a whole album, or just the photos a client
+ picked, as one archive of the originals — streamed, so even a large shoot
+ arrives complete.
- **[@ImScheinox](https://github.com/ImScheinox)** — found and fixed portrait
photos rendering as landscape tiles, because the grid ignored the camera's
EXIF orientation flag. That brought the masonry layout's stagger back for
diff --git a/app/[...path]/AlbumDetailView.tsx b/app/[...path]/AlbumDetailView.tsx
index 330c9ef..15d7c5e 100644
--- a/app/[...path]/AlbumDetailView.tsx
+++ b/app/[...path]/AlbumDetailView.tsx
@@ -31,6 +31,12 @@ interface AlbumDetailViewProps {
proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean;
+ /**
+ * 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
+ * "download selected" action.
+ */
+ downloadArchiveUrl?: string;
/** Neighbouring albums in the surrounding list, for the foot navigation. */
nav?: AlbumNavPair;
/** JSON-LD for this album, or null when no site URL is configured (#472). */
@@ -52,6 +58,7 @@ export function AlbumDetailView({
showGear = true,
proofing,
allowMailto,
+ downloadArchiveUrl,
nav,
structuredData,
}: AlbumDetailViewProps) {
@@ -87,6 +94,24 @@ export function AlbumDetailView({
{t.common.photos(images.length)}
@@ -102,6 +127,8 @@ export function AlbumDetailView({
showExifPanel={showExifPanel}
proofing={proofing}
allowMailto={allowMailto}
+ downloadArchiveUrl={downloadArchiveUrl}
+ albumName={album.albumName}
/>
{nav && }
>
diff --git a/app/[...path]/EssayView.tsx b/app/[...path]/EssayView.tsx
index c527d92..8a003e4 100644
--- a/app/[...path]/EssayView.tsx
+++ b/app/[...path]/EssayView.tsx
@@ -6,6 +6,7 @@ import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
import { FadeIn } from '@/components/FadeIn';
+import { LeafletMap, escapeHtml } from '@/components/LeafletMap';
import type { ParsedEssay, EssayBlock } from '@/lib/essay';
import { renderInlineMarkdown } from '@/lib/essay';
import type { PhotoItem } from './PhotoGrid';
@@ -59,6 +60,77 @@ function EssayViewContent({
? assetMap.get(essay.frontmatter.coverAssetId)
: undefined;
+ /**
+ * One photo tile of a pair or a grid: sized by its real ratio (see
+ * .essay-pair-grid), opens the lightbox, carries the proofing heart. The pair
+ * used to spell this out a second time next to the photo case; a third copy
+ * for the grid is where that stops.
+ */
+ const renderTile = ({ item, index }: { item: PhotoItem; index: number }, key: number) => {
+ const isFav = proofing ? proofing.isFavorite(item.id) : false;
+ return (
+
+
+ );
+ }
+
+ case 'photo-grid': {
+ // Unresolved ids drop out individually; a grid with fewer than two
+ // photos left is not a grid any more and renders as nothing, like a
+ // pair with a missing half.
+ const resolved = block.assetIds.flatMap((id) => {
+ const r = assetMap.get(id);
+ return r ? [r] : [];
+ });
+ if (resolved.length < 2) return null;
+
+ // Rows of three, each justified like a pair: one shared height, widths
+ // from the real ratios, nothing cropped.
+ const rows: (typeof resolved)[] = [];
+ for (let i = 0; i < resolved.length; i += 3) rows.push(resolved.slice(i, i + 3));
+
+ return (
+
+
+
+ {rows.map((row, rIdx) => (
+
+ {row.map(renderTile)}
+
+ ))}
{block.caption && (
+
+ {block.items.map((item, i) => (
+
+
{item.label}
+
+
+ ))}
+
+
+ );
+
+ case 'map': {
+ // The server page attaches `pins` (typed points as typed, photo pins
+ // quantised). Without them — the studio preview — the typed points
+ // still show, so an author sees the map take shape while writing;
+ // photo pins appear on the live page. No pins at all: no map.
+ const pins =
+ block.pins ??
+ block.items.flatMap((item) =>
+ item.kind === 'point'
+ ? [
+ item.label
+ ? { lat: item.lat, lng: item.lng, label: item.label }
+ : { lat: item.lat, lng: item.lng },
+ ]
+ : [],
+ );
+ if (pins.length === 0) return null;
+ return (
+
+
+ ({
+ lat: pin.lat,
+ lng: pin.lng,
+ html: `
${i + 1}
`,
+ ...(pin.label
+ ? {
+ popupHtml: `
${escapeHtml(pin.label)}
`,
+ }
+ : {}),
+ }))}
+ />
+ {block.caption && (
+
+ )}
+
+
+ );
+ }
+
default:
return null;
}
@@ -399,7 +505,13 @@ export function EssayView(props: EssayViewProps) {
}
return (
-
+
);
diff --git a/app/[...path]/PhotoGrid.tsx b/app/[...path]/PhotoGrid.tsx
index d40127c..eadd314 100644
--- a/app/[...path]/PhotoGrid.tsx
+++ b/app/[...path]/PhotoGrid.tsx
@@ -60,6 +60,17 @@ interface PhotoGridProps {
proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean;
+ /**
+ * The ZIP endpoint for this album, when it offers downloads. Lets the
+ * proofing modal offer a "download selected" action (#475).
+ */
+ downloadArchiveUrl?: string;
+ /**
+ * The album's display name. Scopes the proofing favourites to this album —
+ * without it every album shares the provider's default storage key, so a
+ * selection made in one album leaks into the next.
+ */
+ albumName?: string;
}
function PhotoGridInner({
@@ -193,7 +204,7 @@ function PhotoGridInner({
}}
role="button"
tabIndex={0}
- aria-label={`View photo ${index + 1}`}
+ aria-label={t.lightbox.openPhoto(index + 1)}
aria-haspopup="dialog"
style={{
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
@@ -366,7 +377,12 @@ export function PhotoGrid(props: PhotoGridProps) {
}
return (
-
+
);
diff --git a/app/[...path]/essay.css b/app/[...path]/essay.css
index 24a39c2..d56a176 100644
--- a/app/[...path]/essay.css
+++ b/app/[...path]/essay.css
@@ -200,3 +200,87 @@
flex: none;
}
}
+
+/* Map: the entry's geotagged photos as pins, a story-sized Leaflet map. */
+.essay-map {
+ width: 100%;
+ height: 420px;
+ border-radius: var(--radius-sm, 0);
+ overflow: hidden;
+ border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.15));
+ background: var(--bg-secondary, #222);
+}
+
+@media (max-width: 640px) {
+ .essay-map {
+ height: 300px;
+ }
+}
+
+/* Facts: a short definition list in the text column — distance, venue, date. */
+.essay-facts {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem 2rem;
+ max-width: var(--essay-contained, 680px);
+ margin: 2.5rem auto;
+ padding: 1.25rem 0;
+ border-top: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
+ border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
+}
+
+.essay-facts__item {
+ margin: 0;
+}
+
+.essay-facts__label {
+ font-family: var(--font-caption);
+ font-size: 0.7rem;
+ letter-spacing: 0.15em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ margin-bottom: 0.25rem;
+}
+
+.essay-facts__value {
+ margin: 0;
+ font-family: var(--font-sans);
+ font-size: 1rem;
+ color: var(--text-primary);
+}
+
+@media (max-width: 640px) {
+ .essay-facts {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Grid: rows of up to three, each row justified exactly like a pair. */
+.essay-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ width: 100%;
+ max-width: var(--essay-wide);
+ margin: 3.5rem auto;
+}
+
+.essay-grid-row {
+ display: flex;
+ gap: 1.5rem;
+}
+
+.essay-grid-row > .essay-image-wrapper {
+ flex-basis: 0;
+ min-width: 0;
+}
+
+@media (max-width: 640px) {
+ .essay-grid-row {
+ flex-direction: column;
+ }
+
+ .essay-grid-row > .essay-image-wrapper {
+ flex: none;
+ }
+}
diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx
index b3e3293..6ecb2fc 100644
--- a/app/[...path]/page.tsx
+++ b/app/[...path]/page.tsx
@@ -23,6 +23,7 @@ import {
assetCaption,
assetExifSummary,
downloadUrl,
+ archiveUrl,
assetAspectRatio,
} from '@/lib/urls';
import { encodeAssetId, decodeAssetId } from '@/lib/tokens';
@@ -44,7 +45,11 @@ import { albumStructuredData } from '@/lib/structuredData';
import { absoluteUrl } from '@/lib/siteUrl';
import { SubpageGridView } from './SubpageGridView';
import { EssayView } from './EssayView';
-import { parseEssayMarkdown } from '@/lib/essay';
+import { parseEssayMarkdown, type EssayBlock } from '@/lib/essay';
+import { pinsForEntry } from '@/lib/journalMap';
+import { expandAlbumBlocks } from '@/lib/journalAlbum';
+import { mapBlockAssetIds } from '@/lib/journal';
+import { strictestPrecision, type LocationPrecision } from '@/lib/mapPrecision';
import { loadEssayFromFile } from '@/lib/admin/journal-service';
import { getServerDictionary } from '@/lib/i18n/server';
@@ -374,12 +379,13 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
layout={resolveLayout(mergeAlbumGrid(album.id, spGrid))}
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, spGrid))}
backLinkHref={`/${subpageSlug}`}
- backLinkLabel={`Back to ${subpageName}`}
+ backLinkLabel={getServerDictionary().common.backTo(subpageName)}
watermark={config.watermark}
showExifPanel={hasExifPanelContent(config.exif)}
showGear={config.exif.camera}
proofing={proofingFor(subpageData?.subpage)}
allowMailto={config.proofing.allowMailto}
+ downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData}
/>
);
@@ -433,6 +439,43 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
openAlbums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
);
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
+
+ // Album blocks in an essay file: expand them the way the journal page
+ // 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();
+ 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 expanded = expandAlbumBlocks(
+ essayParsed.blocks,
+ (id) => byAlbum.get(id),
+ config.albumManualOrders,
+ );
+ const albumIds = new Set(expanded.albumAssetIds);
+ const known = new Set(allAssets.map((a) => a.id));
+ for (const list of byAlbum.values()) {
+ for (const asset of list) {
+ if (!albumIds.has(asset.id) || known.has(asset.id)) continue;
+ known.add(asset.id);
+ allAssets.push(asset);
+ }
+ }
+ essayParsed = {
+ ...essayParsed,
+ blocks: expanded.blocks.map((b) =>
+ mapBlockAssetIds(b, (id) => (albumIds.has(id) ? encodeAssetId(id) : id)),
+ ),
+ };
+ }
+
const images = toPhotoItems(
allAssets,
config.exif.onHover && config.exif.camera,
@@ -459,6 +502,32 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
};
}
+ // Map blocks: pins from the subpage's own albums, each album's
+ // `location:` precision applied (an asset in two albums takes the
+ // stricter). Dropped entirely when the site has no map.
+ if (essayParsed.blocks.some((b) => b.type === 'map')) {
+ const precisionByAsset = new Map();
+ for (const album of allAlbums) {
+ if (!album) continue;
+ const level = config.albumLocationPrecision[album.id] ?? 'exact';
+ for (const asset of album.assets) {
+ const prev = precisionByAsset.get(asset.id);
+ precisionByAsset.set(asset.id, prev ? strictestPrecision([prev, level]) : level);
+ }
+ }
+ const precisionOf = (id: string) => precisionByAsset.get(id) ?? 'exact';
+ const allIds = allAssets.map((a) => a.id);
+ essayParsed = {
+ ...essayParsed,
+ blocks: essayParsed.blocks.flatMap((b): EssayBlock[] => {
+ if (b.type !== 'map') return [b];
+ if (!config.map) return [];
+ const pins = pinsForEntry(b.items, allAssets, allIds, precisionOf);
+ return [{ type: 'map', caption: b.caption, line: b.line, items: [], pins }];
+ }),
+ };
+ }
+
// A published story is not an album handover, so an essay only gets the
// proofing controls when its subpage asks for them explicitly — the
// global default does not reach in here.
@@ -521,6 +590,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
showGear={config.exif.camera}
proofing={proofingFor(result.subpage)}
allowMailto={config.proofing.allowMailto}
+ downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData}
/>
);
@@ -622,6 +692,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
showGear={config.exif.camera}
proofing={proofingFor()}
allowMailto={config.proofing.allowMailto}
+ downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData}
/>
);
diff --git a/app/admin/__tests__/journal-editor-parts.test.ts b/app/admin/__tests__/journal-editor-parts.test.ts
new file mode 100644
index 0000000..49369f9
--- /dev/null
+++ b/app/admin/__tests__/journal-editor-parts.test.ts
@@ -0,0 +1,126 @@
+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 {
+ clampSplit,
+ splitFromPointer,
+ splitAfterKey,
+ parseStoredSplit,
+ SPLIT_MIN,
+ SPLIT_MAX,
+ SPLIT_DEFAULT,
+} from '../components/journal/splitPane';
+
+/**
+ * The logic that came out of the journal editor when it was split up (#555).
+ * Inside the 2000-line component none of it could be reached by a test.
+ */
+
+const TYPES: JournalBlock['type'][] = [
+ 'heading',
+ 'paragraph',
+ 'quote',
+ 'photo',
+ 'photo-pair',
+ 'photo-grid',
+ 'facts',
+ 'map',
+ 'album',
+];
+
+describe('createBlock', () => {
+ it.each(TYPES)('creates a %s block', (type) => {
+ expect(createBlock(type).type).toBe(type);
+ });
+
+ it('hands out a fresh object each time', () => {
+ const a = createBlock('facts');
+ const b = createBlock('facts');
+ expect(a).not.toBe(b);
+ if (a.type === 'facts' && b.type === 'facts') expect(a.items).not.toBe(b.items);
+ });
+
+ /**
+ * A new block is saved as soon as the author types anywhere else; one that
+ * does not survive the markdown round trip would vanish on the next load.
+ */
+ it.each(TYPES.filter((t) => t !== 'map' && t !== 'album'))(
+ 'a new %s block survives a save and reload',
+ (type) => {
+ const md = serializeJournalMarkdown({
+ frontmatter: { title: 'T' },
+ blocks: [createBlock(type)],
+ referencedAssetIds: [],
+ });
+ expect(parseJournalMarkdown(md).blocks.map((b) => b.type)).toEqual([type]);
+ },
+ );
+});
+
+describe('moveBlock', () => {
+ const blocks = (['paragraph', 'heading', 'quote'] as const).map(createBlock);
+
+ it('moves a block up and down', () => {
+ expect(moveBlock(blocks, 1, 'up')?.map((b) => b.type)).toEqual([
+ 'heading',
+ 'paragraph',
+ 'quote',
+ ]);
+ expect(moveBlock(blocks, 1, 'down')?.map((b) => b.type)).toEqual([
+ 'paragraph',
+ 'quote',
+ 'heading',
+ ]);
+ });
+
+ it('refuses to move past either end', () => {
+ expect(moveBlock(blocks, 0, 'up')).toBeNull();
+ expect(moveBlock(blocks, 2, 'down')).toBeNull();
+ });
+
+ it('leaves the original list alone', () => {
+ moveBlock(blocks, 1, 'up');
+ expect(blocks.map((b) => b.type)).toEqual(['paragraph', 'heading', 'quote']);
+ });
+});
+
+describe('isLegacyAssetRef', () => {
+ it('flags positional references, not UUIDs or empty slots', () => {
+ expect(isLegacyAssetRef('1')).toBe(true);
+ expect(isLegacyAssetRef('')).toBe(false);
+ expect(isLegacyAssetRef('0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0')).toBe(false);
+ });
+});
+
+describe('split pane', () => {
+ it('clamps to its bounds', () => {
+ expect(clampSplit(0)).toBe(SPLIT_MIN);
+ expect(clampSplit(100)).toBe(SPLIT_MAX);
+ expect(clampSplit(50)).toBe(50);
+ });
+
+ it('turns a pointer position into a percentage of the view', () => {
+ expect(splitFromPointer(600, 100, 1000)).toBe(50);
+ expect(splitFromPointer(0, 100, 1000)).toBe(SPLIT_MIN);
+ // A collapsed view has no meaningful position.
+ expect(splitFromPointer(600, 100, 0)).toBeNull();
+ });
+
+ it('nudges with the arrows, further with Shift, and resets with Home', () => {
+ expect(splitAfterKey(50, 'ArrowLeft', false)).toBe(48);
+ expect(splitAfterKey(50, 'ArrowRight', true)).toBe(60);
+ expect(splitAfterKey(SPLIT_MIN, 'ArrowLeft', true)).toBe(SPLIT_MIN);
+ expect(splitAfterKey(SPLIT_MAX, 'ArrowRight', false)).toBe(SPLIT_MAX);
+ expect(splitAfterKey(30, 'Home', false)).toBe(SPLIT_DEFAULT);
+ // Anything else is not the divider's key — Tab must still move focus.
+ expect(splitAfterKey(50, 'Tab', false)).toBeNull();
+ });
+
+ it('only restores a stored width inside the bounds', () => {
+ expect(parseStoredSplit('40')).toBe(40);
+ expect(parseStoredSplit(null)).toBeNull();
+ expect(parseStoredSplit('90')).toBeNull();
+ expect(parseStoredSplit('abc')).toBeNull();
+ });
+});
diff --git a/app/admin/__tests__/useDraft.test.tsx b/app/admin/__tests__/useDraft.test.tsx
new file mode 100644
index 0000000..5aceecd
--- /dev/null
+++ b/app/admin/__tests__/useDraft.test.tsx
@@ -0,0 +1,157 @@
+// @vitest-environment jsdom
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { renderHook, act, render, screen, fireEvent, cleanup } from '@testing-library/react';
+import { useDraft, readDraft } from '../components/useDraft';
+import DraftNotice from '../components/DraftNotice';
+
+/**
+ * #592: leaving the page builder or the journal editor inside the admin — a tab
+ * link, back, Reload, Logout — unmounted it and threw the edits away. The draft
+ * is what brings them back, so these tests walk the round trip an operator
+ * makes: edit, leave (unmount), come back (a fresh hook), load.
+ */
+
+afterEach(() => {
+ cleanup();
+ window.sessionStorage.clear();
+ vi.restoreAllMocks();
+});
+
+type Props = { value: string; dirty: boolean };
+
+function mount(key = 'k', initial: Props = { value: '', dirty: false }) {
+ return renderHook(({ value, dirty }: Props) => useDraft(key, value, dirty), {
+ initialProps: initial,
+ });
+}
+
+describe('useDraft', () => {
+ it('writes nothing before the server state has loaded', () => {
+ // The editor mounts with an empty state; that must not become a draft.
+ mount('k', { value: 'empty', dirty: true });
+ expect(readDraft('k')).toBeNull();
+ });
+
+ it('brings edits back after leaving and returning', () => {
+ const first = mount();
+ act(() => void first.result.current.load('server-v1'));
+ first.rerender({ value: 'my edit', dirty: true });
+ first.unmount();
+
+ const second = mount();
+ let restored: string | null = null;
+ act(() => {
+ restored = second.result.current.load('server-v1');
+ });
+ expect(restored).toBe('my edit');
+ expect(second.result.current.status).toBe('restored');
+ });
+
+ it('holds a draft back when the server changed since', () => {
+ const first = mount();
+ act(() => void first.result.current.load('server-v1'));
+ first.rerender({ value: 'my edit', dirty: true });
+ first.unmount();
+
+ const second = mount();
+ let restored: string | null = 'unset';
+ act(() => {
+ restored = second.result.current.load('server-v2');
+ });
+ // Applying it silently would overwrite the other save.
+ expect(restored).toBeNull();
+ expect(second.result.current.status).toBe('conflict');
+
+ let taken: string | null = null;
+ act(() => {
+ taken = second.result.current.takeConflicting();
+ });
+ expect(taken).toBe('my edit');
+ expect(second.result.current.status).toBe('restored');
+ });
+
+ it('forgets the draft once saved, and bases new edits on the saved state', () => {
+ const first = mount();
+ act(() => void first.result.current.load('server-v1'));
+ first.rerender({ value: 'v2', dirty: true });
+ act(() => first.result.current.saved('server-v2'));
+ first.rerender({ value: 'v2', dirty: false });
+ expect(readDraft('k')).toBeNull();
+
+ // Edits after the save must restore against the saved version, not the
+ // one the editor first loaded.
+ first.rerender({ value: 'v3 edit', dirty: true });
+ first.unmount();
+ const second = mount();
+ let restored: string | null = null;
+ act(() => {
+ restored = second.result.current.load('server-v2');
+ });
+ expect(restored).toBe('v3 edit');
+ });
+
+ it('drops the draft when the edits are undone by a discard', () => {
+ const first = mount();
+ act(() => void first.result.current.load('server-v1'));
+ first.rerender({ value: 'my edit', dirty: true });
+ act(() => first.result.current.discard());
+ first.rerender({ value: 'server-v1', dirty: false });
+ expect(readDraft('k')).toBeNull();
+ expect(first.result.current.status).toBe('none');
+ });
+
+ it('keeps editors apart by key', () => {
+ const a = mount('journal-a');
+ act(() => void a.result.current.load('a'));
+ a.rerender({ value: 'edit in a', dirty: true });
+
+ const b = mount('journal-b');
+ let restored: string | null = 'unset';
+ act(() => {
+ restored = b.result.current.load('a');
+ });
+ expect(restored).toBeNull();
+ });
+
+ it('degrades to no draft when storage is unavailable', () => {
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('SecurityError');
+ });
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new Error('QuotaExceededError');
+ });
+ const { result, rerender } = mount();
+ let restored: string | null = 'unset';
+ act(() => {
+ restored = result.current.load('server-v1');
+ });
+ expect(restored).toBeNull();
+ expect(() => rerender({ value: 'edit', dirty: true })).not.toThrow();
+ });
+});
+
+describe('DraftNotice', () => {
+ const handlers = () => ({ onDiscard: vi.fn(), onRestore: vi.fn(), onDismiss: vi.fn() });
+
+ it('says nothing without a draft', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('says a restored draft is still unsaved and offers to discard it', () => {
+ const h = handlers();
+ render();
+ expect(screen.getByText(/were restored\. They are not saved yet/)).toBeTruthy();
+ fireEvent.click(screen.getByText('Discard changes'));
+ expect(h.onDiscard).toHaveBeenCalled();
+ });
+
+ it('asks before restoring over a newer save', () => {
+ const h = handlers();
+ render();
+ fireEvent.click(screen.getByText('Restore my changes'));
+ expect(h.onRestore).toHaveBeenCalled();
+ fireEvent.click(screen.getByText('Discard them'));
+ expect(h.onDiscard).toHaveBeenCalled();
+ });
+});
diff --git a/app/admin/components/BlockBadge.tsx b/app/admin/components/BlockBadge.tsx
index 0d681d5..3af5176 100644
--- a/app/admin/components/BlockBadge.tsx
+++ b/app/admin/components/BlockBadge.tsx
@@ -1,7 +1,17 @@
'use client';
import type { JournalBlock } from '@/lib/journal';
-import { IconFileText, IconSparkles, IconQuote, IconCamera, IconArrowLeftRight } from './Icons';
+import {
+ IconFileText,
+ IconSparkles,
+ IconQuote,
+ IconCamera,
+ IconArrowLeftRight,
+ IconGrid,
+ IconColumns,
+ IconMap,
+ IconFolder,
+} from './Icons';
type BlockType = JournalBlock['type'];
@@ -19,6 +29,10 @@ const BLOCK_META: Record void;
+ onRestore: () => void;
+ onDismiss: () => void;
+}
+
+/**
+ * Tells the operator that unsaved edits came back — or could not come back on
+ * their own (#592). A restored draft must never look like the saved state: it
+ * is still unsaved, and the save bar says so, but without this line the edits
+ * would simply be there with no hint of where they came from.
+ */
+export default function DraftNotice({
+ status,
+ subject,
+ onDiscard,
+ onRestore,
+ onDismiss,
+}: DraftNoticeProps) {
+ if (status === 'none') return null;
+
+ return (
+
+ {status === 'restored' ? (
+ <>
+ Your unsaved changes to the {subject} were restored. They are not saved yet.
+
+
+
+
+ >
+ ) : (
+ <>
+
+ You have unsaved changes to the {subject} from earlier, but it was saved elsewhere
+ since. Restoring them replaces that version when you save.
+
+
+
+
+
+ >
+ )}
+
- )}
-
- {/* 2. PARAGRAPH BLOCK
+ {/* 2. PARAGRAPH BLOCK
The textarea shows `html` raw, as the journal editor does. It used
to strip the tags for display — `` to `**`, everything else
@@ -252,197 +299,135 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
disagreed about the same file. serializeJournalMarkdown() turns the
HTML back into markdown on save and handles `` too, so there
was nothing to convert here in the first place. */}
- {block.type === 'paragraph' && (
-