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({

)} {album.description &&

{album.description}

} + {downloadArchiveUrl && ( + + + {t.common.downloadAlbum} + + )}

{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 ( +

setLightboxIndex(index)} + style={{ + ...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}), + flexGrow: item.aspectRatio ?? 1.5, + aspectRatio: `${item.aspectRatio ?? 1.5}`, + }} + > + + {proofing && ( + + )} +
+ ); + }; + const renderBlock = (block: EssayBlock, idx: number) => { switch (block.type) { case 'heading': { @@ -183,72 +255,42 @@ function EssayViewContent({ return (
-
- {[res1, res2].map(({ item, index }, pIdx) => { - const isFav = proofing ? proofing.isFavorite(item.id) : false; - return ( -
setLightboxIndex(index)} - style={{ - ...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}), - // Width proportional to the ratio — see .essay-pair-grid. - flexGrow: item.aspectRatio ?? 1.5, - aspectRatio: `${item.aspectRatio ?? 1.5}`, - }} - > - - {proofing && ( - - )} -
- ); - })} +
{[res1, res2].map(renderTile)}
+ {block.caption && ( +
+ )} +
+ + ); + } + + 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. + + + + + + + )} +
+ ); +} diff --git a/app/admin/components/EssayBlockEditor.tsx b/app/admin/components/EssayBlockEditor.tsx index 3f8c3f1..16776a5 100644 --- a/app/admin/components/EssayBlockEditor.tsx +++ b/app/admin/components/EssayBlockEditor.tsx @@ -4,19 +4,29 @@ import { useState, useCallback } from 'react'; import { parseEssayMarkdown, serializeEssayMarkdown, + collectAssetIds, + isValidCoordinate, type ParsedEssay, type EssayBlock, + type MapItem, } from '@/lib/essay'; +import { arrayMove } from '@dnd-kit/sortable'; +import { SortableBlockList, SortableBlockCard } from './SortableBlocks'; import { IconCamera, IconChevronUp, IconChevronDown, IconTrash, - IconGripVertical, IconQuote, IconFileText, IconSparkles, IconArrowLeftRight, + IconGrid, + IconColumns, + IconMap, + IconFolder, + IconPlus, + IconX, } from './Icons'; import { BlockBadge } from './BlockBadge'; @@ -47,15 +57,7 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo const updatedEssay: ParsedEssay = { ...essay, blocks: newBlocks, - referencedAssetIds: Array.from( - new Set( - newBlocks.flatMap((b) => { - if (b.type === 'photo') return [b.assetId]; - if (b.type === 'photo-pair') return b.assetIds; - return []; - }), - ), - ), + referencedAssetIds: collectAssetIds(newBlocks), }; setEssay(updatedEssay); @@ -75,6 +77,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo updateBlocks(newBlocks); }; + const handleReorderBlock = (from: number, to: number) => { + updateBlocks(arrayMove(essay.blocks, from, to)); + }; + const handleDeleteBlock = (index: number) => { const newBlocks = essay.blocks.filter((_, i) => i !== index); updateBlocks(newBlocks); @@ -114,6 +120,24 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo caption: '', }; break; + case 'photo-grid': + newBlock = { type: 'photo-grid', assetIds: ['', '', ''], caption: '' }; + break; + case 'facts': + newBlock = { + type: 'facts', + items: [ + { label: '', value: '' }, + { label: '', value: '' }, + ], + }; + break; + case 'map': + newBlock = { type: 'map', caption: '', line: true, items: [] }; + break; + case 'album': + newBlock = { type: 'album', albumId: '', layout: 'grid' }; + break; } updateBlocks([...essay.blocks, newBlock]); @@ -145,6 +169,13 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo > + Pullquote +
+ + +
{essay.blocks.length === 0 && ( @@ -169,80 +221,75 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo )} {/* Render Blocks */} - {essay.blocks.map((block, idx) => ( -
- {/* Card Header */} -
-
- - -
+ + {essay.blocks.map((block, idx) => ( + } + actions={ +
+ + + +
+ } + > + {/* Block Form Fields */} -
- - - -
-
+ {/* 1. HEADING BLOCK */} + {block.type === 'heading' && ( +
+ + handleUpdateBlock(idx, { ...block, text: e.target.value })} + placeholder="Section heading..." + style={{ + flex: 1, + fontWeight: block.level === 1 ? 700 : block.level === 2 ? 600 : 500, + }} + /> +
+ )} - {/* Block Form Fields */} - - {/* 1. HEADING BLOCK */} - {block.type === 'heading' && ( -
- - handleUpdateBlock(idx, { ...block, text: e.target.value })} - placeholder="Section heading..." - style={{ - flex: 1, - fontWeight: block.level === 1 ? 700 : block.level === 2 ? 600 : 500, - }} - /> -
- )} - - {/* 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' && ( -