release: v0.17.0 (#677)

See CHANGELOG.md, section [0.17.0] — 2026-09-23.
This commit is contained in:
Ralksta
2026-09-23 12:09:08 +02:00
committed by GitHub
parent a3a6319c29
commit 89a8fac564
78 changed files with 9485 additions and 2050 deletions

View File

@@ -68,6 +68,12 @@ AUTH_SECRET=change-me-to-a-long-random-string
# Leave unset or empty to disable the admin UI entirely. # Leave unset or empty to disable the admin UI entirely.
# ADMIN_PASSWORD=your-secure-admin-password # 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 ───────────────────────────────────────────────────── # ── Site URL ─────────────────────────────────────────────────────
# Used for sitemap.xml, the feed and structured data when `url:` is not # 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 # set in settings.yaml. The settings file takes precedence, so the admin

View File

@@ -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 Releases up to and including v0.9.2 are documented in the
[GitHub releases](https://github.com/ralksta/immich-folio/releases). [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 ## [0.16.0] — 2026-09-22
### Security ### Security

View File

@@ -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 **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 `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 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. a control that must stay disabled, a keyboard shortcut.
## Code style ## Code style

View File

@@ -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 - **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 - **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 - **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 - **Unlisted subpages** _(experimental)_ — reachable by direct link, absent from the navigation
- **Subpage on/off toggle** — take a page offline without deleting it - **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 - **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 - **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 - **Lightbox watermark** — configurable overlay on fullscreen images
- **Privacy-friendly analytics** — cookieless view counts, no third parties, can be switched off - **Privacy-friendly analytics** — cookieless view counts, no third parties, can be switched off
- **Dynamic OG images** — auto-generated social preview images per album - **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 ### 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)** → **[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 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 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 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 - **[@ImScheinox](https://github.com/ImScheinox)** — found and fixed portrait
photos rendering as landscape tiles, because the grid ignored the camera's photos rendering as landscape tiles, because the grid ignored the camera's
EXIF orientation flag. That brought the masonry layout's stagger back for EXIF orientation flag. That brought the masonry layout's stagger back for

View File

@@ -31,6 +31,12 @@ interface AlbumDetailViewProps {
proofing?: boolean; proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */ /** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean; 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. */ /** Neighbouring albums in the surrounding list, for the foot navigation. */
nav?: AlbumNavPair; nav?: AlbumNavPair;
/** JSON-LD for this album, or null when no site URL is configured (#472). */ /** JSON-LD for this album, or null when no site URL is configured (#472). */
@@ -52,6 +58,7 @@ export function AlbumDetailView({
showGear = true, showGear = true,
proofing, proofing,
allowMailto, allowMailto,
downloadArchiveUrl,
nav, nav,
structuredData, structuredData,
}: AlbumDetailViewProps) { }: AlbumDetailViewProps) {
@@ -87,6 +94,24 @@ export function AlbumDetailView({
</p> </p>
)} )}
{album.description && <p className="album-header__description">{album.description}</p>} {album.description && <p className="album-header__description">{album.description}</p>}
{downloadArchiveUrl && (
<a className="album-header__download" href={downloadArchiveUrl}>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{t.common.downloadAlbum}
</a>
)}
</div> </div>
<p className="album-header__meta"> <p className="album-header__meta">
<span className="album-header__meta-count">{t.common.photos(images.length)}</span> <span className="album-header__meta-count">{t.common.photos(images.length)}</span>
@@ -102,6 +127,8 @@ export function AlbumDetailView({
showExifPanel={showExifPanel} showExifPanel={showExifPanel}
proofing={proofing} proofing={proofing}
allowMailto={allowMailto} allowMailto={allowMailto}
downloadArchiveUrl={downloadArchiveUrl}
albumName={album.albumName}
/> />
{nav && <AlbumNav {...nav} />} {nav && <AlbumNav {...nav} />}
</> </>

View File

@@ -6,6 +6,7 @@ import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
import { ProofingProvider, useProofing } from '@/components/ProofingContext'; import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal'; import { ProofingModal } from '@/components/ProofingModal';
import { FadeIn } from '@/components/FadeIn'; import { FadeIn } from '@/components/FadeIn';
import { LeafletMap, escapeHtml } from '@/components/LeafletMap';
import type { ParsedEssay, EssayBlock } from '@/lib/essay'; import type { ParsedEssay, EssayBlock } from '@/lib/essay';
import { renderInlineMarkdown } from '@/lib/essay'; import { renderInlineMarkdown } from '@/lib/essay';
import type { PhotoItem } from './PhotoGrid'; import type { PhotoItem } from './PhotoGrid';
@@ -59,6 +60,77 @@ function EssayViewContent({
? assetMap.get(essay.frontmatter.coverAssetId) ? assetMap.get(essay.frontmatter.coverAssetId)
: undefined; : 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 (
<div
key={key}
className="essay-image-wrapper photo-grid__item"
onClick={() => setLightboxIndex(index)}
style={{
...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
flexGrow: item.aspectRatio ?? 1.5,
aspectRatio: `${item.aspectRatio ?? 1.5}`,
}}
>
<Image
src={item.previewUrl || item.thumbUrl}
alt=""
fill
sizes="(max-width: 640px) 100vw, 550px"
loading="lazy"
{...(item.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: item.blurDataURL }
: {})}
/>
{proofing && (
<button
type="button"
className="photo-grid__fav-btn"
onClick={(e) => {
e.stopPropagation();
proofing.toggleFavorite(item.id);
}}
aria-label={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
style={{
position: 'absolute',
top: '12px',
right: '12px',
zIndex: 5,
width: '44px',
height: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'none',
border: 'none',
cursor: 'pointer',
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
}}
>
<svg
viewBox="0 0 24 24"
width="24"
height="24"
fill={isFav ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
</button>
)}
</div>
);
};
const renderBlock = (block: EssayBlock, idx: number) => { const renderBlock = (block: EssayBlock, idx: number) => {
switch (block.type) { switch (block.type) {
case 'heading': { case 'heading': {
@@ -183,72 +255,42 @@ function EssayViewContent({
return ( return (
<FadeIn key={idx}> <FadeIn key={idx}>
<div className="essay-figure essay-figure--wide"> <div className="essay-figure essay-figure--wide">
<div className="essay-pair-grid"> <div className="essay-pair-grid">{[res1, res2].map(renderTile)}</div>
{[res1, res2].map(({ item, index }, pIdx) => { {block.caption && (
const isFav = proofing ? proofing.isFavorite(item.id) : false; <figcaption
return ( className="essay-figcaption"
<div dangerouslySetInnerHTML={{ __html: block.caption }}
key={pIdx}
className="essay-image-wrapper photo-grid__item"
onClick={() => 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}`,
}}
>
<Image
src={item.previewUrl || item.thumbUrl}
alt=""
fill
sizes="(max-width: 640px) 100vw, 550px"
loading="lazy"
{...(item.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: item.blurDataURL }
: {})}
/> />
{proofing && (
<button
type="button"
className="photo-grid__fav-btn"
onClick={(e) => {
e.stopPropagation();
proofing.toggleFavorite(item.id);
}}
aria-label={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
style={{
position: 'absolute',
top: '12px',
right: '12px',
zIndex: 5,
width: '44px',
height: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'none',
border: 'none',
cursor: 'pointer',
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
}}
>
<svg
viewBox="0 0 24 24"
width="24"
height="24"
fill={isFav ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
</button>
)} )}
</div> </div>
</FadeIn>
); );
})} }
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 (
<FadeIn key={idx}>
<div className="essay-figure essay-figure--wide">
<div className="essay-grid">
{rows.map((row, rIdx) => (
<div key={rIdx} className="essay-grid-row">
{row.map(renderTile)}
</div>
))}
</div> </div>
{block.caption && ( {block.caption && (
<figcaption <figcaption
@@ -261,6 +303,70 @@ function EssayViewContent({
); );
} }
case 'facts':
if (block.items.length === 0) return null;
return (
<FadeIn key={idx}>
<dl className="essay-facts">
{block.items.map((item, i) => (
<div key={i} className="essay-facts__item">
<dt className="essay-facts__label">{item.label}</dt>
<dd
className="essay-facts__value"
dangerouslySetInnerHTML={{ __html: item.value }}
/>
</div>
))}
</dl>
</FadeIn>
);
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 (
<FadeIn key={idx}>
<figure className="essay-figure essay-figure--wide">
<LeafletMap
className="essay-map"
fitMaxZoom={13}
line={block.line}
markers={pins.map((pin, i) => ({
lat: pin.lat,
lng: pin.lng,
html: `<div class="map-marker">${i + 1}</div>`,
...(pin.label
? {
popupHtml: `<div class="map-popup"><div class="map-popup__body"><h3 class="map-popup__city">${escapeHtml(pin.label)}</h3></div></div>`,
}
: {}),
}))}
/>
{block.caption && (
<figcaption
className="essay-figcaption"
dangerouslySetInnerHTML={{ __html: block.caption }}
/>
)}
</figure>
</FadeIn>
);
}
default: default:
return null; return null;
} }
@@ -399,7 +505,13 @@ export function EssayView(props: EssayViewProps) {
} }
return ( return (
<ProofingProvider albumTokens={albumTokens} allowMailto={props.allowMailto ?? true}> <ProofingProvider
albumTokens={albumTokens}
// The essay's own title, so its favourites do not share the provider's
// default key with every album (and story) on the site.
albumName={props.title || props.essay.frontmatter.title}
allowMailto={props.allowMailto ?? true}
>
<EssayViewContent {...props} /> <EssayViewContent {...props} />
</ProofingProvider> </ProofingProvider>
); );

View File

@@ -60,6 +60,17 @@ interface PhotoGridProps {
proofing?: boolean; proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */ /** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean; 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({ function PhotoGridInner({
@@ -193,7 +204,7 @@ function PhotoGridInner({
}} }}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={`View photo ${index + 1}`} aria-label={t.lightbox.openPhoto(index + 1)}
aria-haspopup="dialog" aria-haspopup="dialog"
style={{ style={{
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}), ...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
@@ -366,7 +377,12 @@ export function PhotoGrid(props: PhotoGridProps) {
} }
return ( return (
<ProofingProvider albumTokens={albumTokens} allowMailto={props.allowMailto ?? true}> <ProofingProvider
albumTokens={albumTokens}
albumName={props.albumName}
allowMailto={props.allowMailto ?? true}
downloadArchiveUrl={props.downloadArchiveUrl}
>
<PhotoGridInner {...props} /> <PhotoGridInner {...props} />
</ProofingProvider> </ProofingProvider>
); );

View File

@@ -200,3 +200,87 @@
flex: none; 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;
}
}

View File

@@ -23,6 +23,7 @@ import {
assetCaption, assetCaption,
assetExifSummary, assetExifSummary,
downloadUrl, downloadUrl,
archiveUrl,
assetAspectRatio, assetAspectRatio,
} from '@/lib/urls'; } from '@/lib/urls';
import { encodeAssetId, decodeAssetId } from '@/lib/tokens'; import { encodeAssetId, decodeAssetId } from '@/lib/tokens';
@@ -44,7 +45,11 @@ import { albumStructuredData } from '@/lib/structuredData';
import { absoluteUrl } from '@/lib/siteUrl'; import { absoluteUrl } from '@/lib/siteUrl';
import { SubpageGridView } from './SubpageGridView'; import { SubpageGridView } from './SubpageGridView';
import { EssayView } from './EssayView'; 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 { loadEssayFromFile } from '@/lib/admin/journal-service';
import { getServerDictionary } from '@/lib/i18n/server'; import { getServerDictionary } from '@/lib/i18n/server';
@@ -374,12 +379,13 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
layout={resolveLayout(mergeAlbumGrid(album.id, spGrid))} layout={resolveLayout(mergeAlbumGrid(album.id, spGrid))}
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, spGrid))} gridStyle={buildGridStyle(mergeAlbumGrid(album.id, spGrid))}
backLinkHref={`/${subpageSlug}`} backLinkHref={`/${subpageSlug}`}
backLinkLabel={`Back to ${subpageName}`} backLinkLabel={getServerDictionary().common.backTo(subpageName)}
watermark={config.watermark} watermark={config.watermark}
showExifPanel={hasExifPanelContent(config.exif)} showExifPanel={hasExifPanelContent(config.exif)}
showGear={config.exif.camera} showGear={config.exif.camera}
proofing={proofingFor(subpageData?.subpage)} proofing={proofingFor(subpageData?.subpage)}
allowMailto={config.proofing.allowMailto} allowMailto={config.proofing.allowMailto}
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData} {...heroData}
/> />
); );
@@ -433,6 +439,43 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
openAlbums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)), openAlbums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
); );
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : [])); 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<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 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( const images = toPhotoItems(
allAssets, allAssets,
config.exif.onHover && config.exif.camera, 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<string, LocationPrecision>();
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 // A published story is not an album handover, so an essay only gets the
// proofing controls when its subpage asks for them explicitly — the // proofing controls when its subpage asks for them explicitly — the
// global default does not reach in here. // global default does not reach in here.
@@ -521,6 +590,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
showGear={config.exif.camera} showGear={config.exif.camera}
proofing={proofingFor(result.subpage)} proofing={proofingFor(result.subpage)}
allowMailto={config.proofing.allowMailto} allowMailto={config.proofing.allowMailto}
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData} {...heroData}
/> />
); );
@@ -622,6 +692,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
showGear={config.exif.camera} showGear={config.exif.camera}
proofing={proofingFor()} proofing={proofingFor()}
allowMailto={config.proofing.allowMailto} allowMailto={config.proofing.allowMailto}
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData} {...heroData}
/> />
); );

View File

@@ -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();
});
});

View File

@@ -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(<DraftNotice status="none" subject="entry" {...handlers()} />);
expect(container.firstChild).toBeNull();
});
it('says a restored draft is still unsaved and offers to discard it', () => {
const h = handlers();
render(<DraftNotice status="restored" subject="entry" {...h} />);
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(<DraftNotice status="conflict" subject="page structure" {...h} />);
fireEvent.click(screen.getByText('Restore my changes'));
expect(h.onRestore).toHaveBeenCalled();
fireEvent.click(screen.getByText('Discard them'));
expect(h.onDiscard).toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,17 @@
'use client'; 'use client';
import type { JournalBlock } from '@/lib/journal'; 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']; type BlockType = JournalBlock['type'];
@@ -19,6 +29,10 @@ const BLOCK_META: Record<BlockType, { label: string; Icon: typeof IconFileText }
quote: { label: 'Quote', Icon: IconQuote }, quote: { label: 'Quote', Icon: IconQuote },
photo: { label: 'Photo', Icon: IconCamera }, photo: { label: 'Photo', Icon: IconCamera },
'photo-pair': { label: 'Photo Pair', Icon: IconArrowLeftRight }, 'photo-pair': { label: 'Photo Pair', Icon: IconArrowLeftRight },
'photo-grid': { label: 'Photo Grid', Icon: IconGrid },
facts: { label: 'Facts', Icon: IconColumns },
map: { label: 'Map', Icon: IconMap },
album: { label: 'Album', Icon: IconFolder },
}; };
interface BlockBadgeProps { interface BlockBadgeProps {

View File

@@ -0,0 +1,61 @@
'use client';
import type { DraftStatus } from './useDraft';
interface DraftNoticeProps {
status: DraftStatus;
/** What the draft belongs to, e.g. "page structure" or "entry". */
subject: string;
onDiscard: () => 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 (
<div className="admin-notice draft-notice" role="status">
{status === 'restored' ? (
<>
<span>Your unsaved changes to the {subject} were restored. They are not saved yet.</span>
<span className="draft-notice-actions">
<button type="button" className="admin-btn admin-btn-xs" onClick={onDiscard}>
Discard changes
</button>
<button type="button" className="admin-btn admin-btn-xs" onClick={onDismiss}>
OK
</button>
</span>
</>
) : (
<>
<span>
You have unsaved changes to the {subject} from earlier, but it was saved elsewhere
since. Restoring them replaces that version when you save.
</span>
<span className="draft-notice-actions">
<button type="button" className="admin-btn admin-btn-xs" onClick={onRestore}>
Restore my changes
</button>
<button type="button" className="admin-btn admin-btn-xs" onClick={onDiscard}>
Discard them
</button>
</span>
</>
)}
</div>
);
}

View File

@@ -4,19 +4,29 @@ import { useState, useCallback } from 'react';
import { import {
parseEssayMarkdown, parseEssayMarkdown,
serializeEssayMarkdown, serializeEssayMarkdown,
collectAssetIds,
isValidCoordinate,
type ParsedEssay, type ParsedEssay,
type EssayBlock, type EssayBlock,
type MapItem,
} from '@/lib/essay'; } from '@/lib/essay';
import { arrayMove } from '@dnd-kit/sortable';
import { SortableBlockList, SortableBlockCard } from './SortableBlocks';
import { import {
IconCamera, IconCamera,
IconChevronUp, IconChevronUp,
IconChevronDown, IconChevronDown,
IconTrash, IconTrash,
IconGripVertical,
IconQuote, IconQuote,
IconFileText, IconFileText,
IconSparkles, IconSparkles,
IconArrowLeftRight, IconArrowLeftRight,
IconGrid,
IconColumns,
IconMap,
IconFolder,
IconPlus,
IconX,
} from './Icons'; } from './Icons';
import { BlockBadge } from './BlockBadge'; import { BlockBadge } from './BlockBadge';
@@ -47,15 +57,7 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
const updatedEssay: ParsedEssay = { const updatedEssay: ParsedEssay = {
...essay, ...essay,
blocks: newBlocks, blocks: newBlocks,
referencedAssetIds: Array.from( referencedAssetIds: collectAssetIds(newBlocks),
new Set(
newBlocks.flatMap((b) => {
if (b.type === 'photo') return [b.assetId];
if (b.type === 'photo-pair') return b.assetIds;
return [];
}),
),
),
}; };
setEssay(updatedEssay); setEssay(updatedEssay);
@@ -75,6 +77,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
updateBlocks(newBlocks); updateBlocks(newBlocks);
}; };
const handleReorderBlock = (from: number, to: number) => {
updateBlocks(arrayMove(essay.blocks, from, to));
};
const handleDeleteBlock = (index: number) => { const handleDeleteBlock = (index: number) => {
const newBlocks = essay.blocks.filter((_, i) => i !== index); const newBlocks = essay.blocks.filter((_, i) => i !== index);
updateBlocks(newBlocks); updateBlocks(newBlocks);
@@ -114,6 +120,24 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
caption: '', caption: '',
}; };
break; 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]); updateBlocks([...essay.blocks, newBlock]);
@@ -145,6 +169,13 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
> >
<IconQuote size={13} /> + Pullquote <IconQuote size={13} /> + Pullquote
</button> </button>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() => handleAddBlock('facts')}
>
<IconColumns size={13} /> + Facts
</button>
<div className="essay-toolbar-divider" /> <div className="essay-toolbar-divider" />
<button <button
type="button" type="button"
@@ -160,6 +191,27 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
> >
<IconCamera size={13} /> + 2-Photo Pair <IconCamera size={13} /> + 2-Photo Pair
</button> </button>
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-primary"
onClick={() => handleAddBlock('photo-grid')}
>
<IconGrid size={13} /> + Photo Grid
</button>
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-primary"
onClick={() => handleAddBlock('album')}
>
<IconFolder size={13} /> + Album
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() => handleAddBlock('map')}
>
<IconMap size={13} /> + Map
</button>
</div> </div>
{essay.blocks.length === 0 && ( {essay.blocks.length === 0 && (
@@ -169,18 +221,13 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
)} )}
{/* Render Blocks */} {/* Render Blocks */}
<SortableBlockList count={essay.blocks.length} onReorder={handleReorderBlock}>
{essay.blocks.map((block, idx) => ( {essay.blocks.map((block, idx) => (
<div key={idx} className="essay-block-card"> <SortableBlockCard
{/* Card Header */} key={idx}
<div className="essay-block-card-header"> index={idx}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> badge={<BlockBadge type={block.type} index={idx + 1} />}
<IconGripVertical actions={
size={14}
style={{ color: 'var(--admin-text-muted)', cursor: 'grab' }}
/>
<BlockBadge type={block.type} index={idx + 1} />
</div>
<div className="essay-block-actions"> <div className="essay-block-actions">
<button <button
type="button" type="button"
@@ -210,8 +257,8 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
<IconTrash size={15} /> <IconTrash size={15} />
</button> </button>
</div> </div>
</div> }
>
{/* Block Form Fields */} {/* Block Form Fields */}
{/* 1. HEADING BLOCK */} {/* 1. HEADING BLOCK */}
@@ -355,7 +402,9 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
<input <input
type="text" type="text"
value={block.assetId} value={block.assetId}
onChange={(e) => handleUpdateBlock(idx, { ...block, assetId: e.target.value })} onChange={(e) =>
handleUpdateBlock(idx, { ...block, assetId: e.target.value })
}
placeholder="Immich Asset UUID..." placeholder="Immich Asset UUID..."
style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }} style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }}
/> />
@@ -397,11 +446,16 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
style={{ width: '80px', height: '60px' }} style={{ width: '80px', height: '60px' }}
/> />
) : ( ) : (
<div className="essay-photo-empty" style={{ width: '80px', height: '60px' }}> <div
className="essay-photo-empty"
style={{ width: '80px', height: '60px' }}
>
<IconCamera size={16} /> <IconCamera size={16} />
</div> </div>
)} )}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}> <div
style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}
>
{onSelectPhoto && ( {onSelectPhoto && (
<button <button
type="button" type="button"
@@ -476,11 +530,16 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
style={{ width: '80px', height: '60px' }} style={{ width: '80px', height: '60px' }}
/> />
) : ( ) : (
<div className="essay-photo-empty" style={{ width: '80px', height: '60px' }}> <div
className="essay-photo-empty"
style={{ width: '80px', height: '60px' }}
>
<IconCamera size={16} /> <IconCamera size={16} />
</div> </div>
)} )}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}> <div
style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}
>
{onSelectPhoto && ( {onSelectPhoto && (
<button <button
type="button" type="button"
@@ -524,8 +583,454 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
/> />
</div> </div>
)} )}
{/* 6. PHOTO-GRID BLOCK */}
{block.type === 'photo-grid' && (
<div className="essay-photo-card">
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(170px, 1fr))',
gap: '1rem',
}}
>
{block.assetIds.map((assetId, pIdx) => (
<div
key={pIdx}
style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span
style={{
fontSize: '0.75rem',
fontWeight: 600,
color: 'var(--admin-text-secondary)',
}}
>
Photo {pIdx + 1}
</span>
{block.assetIds.length > 3 && (
<button
type="button"
className="admin-btn-icon"
title="Remove photo"
onClick={() =>
handleUpdateBlock(idx, {
...block,
assetIds: block.assetIds.filter((_, i) => i !== pIdx),
})
}
>
<IconX size={13} />
</button>
)}
</div>
<div className="essay-photo-preview-container">
{assetId ? (
<img
src={`/api/admin/thumbnail/${assetId}`}
alt=""
className="essay-photo-thumbnail"
style={{ width: '80px', height: '60px' }}
/>
) : (
<div
className="essay-photo-empty"
style={{ width: '80px', height: '60px' }}
>
<IconCamera size={16} />
</div>
)}
<div
style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}
>
{onSelectPhoto && (
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
onSelectPhoto((pickedId) => {
const ids = [...block.assetIds];
ids[pIdx] = pickedId;
handleUpdateBlock(idx, { ...block, assetIds: ids });
})
}
>
<IconCamera size={12} /> Select
</button>
)}
<input
type="text"
value={assetId}
onChange={(e) => {
const ids = [...block.assetIds];
ids[pIdx] = e.target.value;
handleUpdateBlock(idx, { ...block, assetIds: ids });
}}
placeholder={`UUID ${pIdx + 1}...`}
style={{ fontSize: '0.75rem' }}
/>
</div>
</div>
</div> </div>
))} ))}
</div> </div>
<button
type="button"
className="admin-btn admin-btn-xs"
style={{ alignSelf: 'flex-start' }}
onClick={() =>
handleUpdateBlock(idx, { ...block, assetIds: [...block.assetIds, ''] })
}
>
<IconPlus size={12} /> Add photo
</button>
<input
type="text"
value={block.caption || ''}
onChange={(e) =>
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
}
placeholder="Grid caption (optional)"
/>
</div>
)}
{/* 7. FACTS BLOCK */}
{block.type === 'facts' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{block.items.map((item, fIdx) => (
<div
key={fIdx}
style={{
display: 'grid',
gridTemplateColumns: '1fr 2fr auto',
gap: '8px',
alignItems: 'center',
}}
>
<input
type="text"
value={item.label}
placeholder="Label, e.g. Distance"
onChange={(e) =>
handleUpdateBlock(idx, {
...block,
items: block.items.map((it, i) =>
i === fIdx ? { ...it, label: e.target.value } : it,
),
})
}
/>
<input
type="text"
value={item.value}
placeholder="Value, e.g. 21 km"
onChange={(e) =>
handleUpdateBlock(idx, {
...block,
items: block.items.map((it, i) =>
i === fIdx ? { ...it, value: e.target.value } : it,
),
})
}
/>
<button
type="button"
className="admin-btn-icon"
title="Remove fact"
disabled={block.items.length <= 1}
onClick={() =>
handleUpdateBlock(idx, {
...block,
items: block.items.filter((_, i) => i !== fIdx),
})
}
>
<IconX size={13} />
</button>
</div>
))}
<button
type="button"
className="admin-btn admin-btn-xs"
style={{ alignSelf: 'flex-start' }}
onClick={() =>
handleUpdateBlock(idx, {
...block,
items: [...block.items, { label: '', value: '' }],
})
}
>
<IconPlus size={12} /> Add fact
</button>
</div>
)}
{/* 9. ALBUM BLOCK */}
{block.type === 'album' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<input
type="text"
value={block.albumId}
placeholder="Immich album UUID (the album picker in Pages shows ids)"
style={{ fontSize: '0.75rem' }}
onChange={(e) => handleUpdateBlock(idx, { ...block, albumId: e.target.value })}
/>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<input
type="number"
min={1}
placeholder="Count (all)"
aria-label="Count"
style={{ flex: '1 1 100px' }}
value={block.count ?? ''}
onChange={(e) =>
handleUpdateBlock(idx, {
...block,
count: e.target.value ? Number(e.target.value) : undefined,
})
}
/>
<input
type="number"
min={0}
placeholder="Skip (0)"
aria-label="Skip"
style={{ flex: '1 1 100px' }}
value={block.skip ?? ''}
onChange={(e) =>
handleUpdateBlock(idx, {
...block,
skip: e.target.value ? Number(e.target.value) : undefined,
})
}
/>
<select
aria-label="Layout"
style={{ flex: '1 1 140px' }}
value={block.layout}
onChange={(e) =>
handleUpdateBlock(idx, {
...block,
layout: e.target.value as 'grid' | 'pairs' | 'wide',
})
}
>
<option value="grid">Grid (rows of three)</option>
<option value="pairs">Pairs (rows of two)</option>
<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 })
}
/>
<span style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }}>
Expanded into photo blocks when the page renders — the album&apos;s order, a
manual gallery order first. Leave count empty for the whole album.
</span>
</div>
)}
{/* 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 })
}
placeholder="Caption, e.g. Busan → Seoul (optional)"
/>
{block.items.map((item, mIdx) => {
const setItem = (next: MapItem) =>
handleUpdateBlock(idx, {
...block,
items: block.items.map((it, i) => (i === mIdx ? next : it)),
});
return (
<div
key={mIdx}
style={{
display: 'flex',
gap: '6px',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<span style={{ fontSize: '0.7rem', color: 'var(--admin-text-secondary)' }}>
{mIdx + 1}
</span>
{item.kind === 'point' && (
<>
<input
type="text"
value={item.label ?? ''}
placeholder="Label (optional)"
style={{ flex: '1 1 140px' }}
onChange={(e) =>
setItem({ ...item, label: e.target.value || undefined })
}
/>
<input
type="number"
step="any"
aria-label="Latitude"
placeholder="Lat"
style={{ flex: '0 1 110px' }}
value={Number.isFinite(item.lat) ? item.lat : ''}
onChange={(e) => setItem({ ...item, lat: parseFloat(e.target.value) })}
/>
<input
type="number"
step="any"
aria-label="Longitude"
placeholder="Lng"
style={{ flex: '0 1 110px' }}
value={Number.isFinite(item.lng) ? item.lng : ''}
onChange={(e) => setItem({ ...item, lng: parseFloat(e.target.value) })}
/>
</>
)}
{item.kind === 'photo' && (
<>
{onSelectPhoto && (
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
onSelectPhoto((pickedId) =>
setItem({ kind: 'photo', assetId: pickedId }),
)
}
>
<IconCamera size={12} /> {item.assetId ? 'Change' : 'Select'}
</button>
)}
<input
type="text"
value={item.assetId}
placeholder="Photo UUID..."
style={{ flex: '1 1 200px', fontSize: '0.75rem' }}
onChange={(e) => setItem({ kind: 'photo', assetId: e.target.value })}
/>
</>
)}
{item.kind === 'all-photos' && (
<span style={{ flex: 1, fontSize: '0.8rem' }}>
All geotagged photos of this page&apos;s albums, in order
</span>
)}
<button
type="button"
className="admin-btn-icon"
title="Remove pin"
onClick={() =>
handleUpdateBlock(idx, {
...block,
items: block.items.filter((_, i) => i !== mIdx),
})
}
>
<IconX size={13} />
</button>
</div>
);
})}
{block.items.some(
(it) => it.kind === 'point' && !isValidCoordinate(it.lat, it.lng),
) && (
<p className="empty-hint" style={{ color: 'var(--admin-warning)' }}>
A point needs a latitude within ±90 and a longitude within ±180; points without
valid coordinates are not saved.
</p>
)}
<div
style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}
>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
handleUpdateBlock(idx, {
...block,
items: [
...block.items,
{ kind: 'point', lat: Number.NaN, lng: Number.NaN },
],
})
}
>
<IconPlus size={12} /> Point
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
handleUpdateBlock(idx, {
...block,
items: [...block.items, { kind: 'photo', assetId: '' }],
})
}
>
<IconCamera size={12} /> Photo pin
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
disabled={block.items.some((it) => it.kind === 'all-photos')}
onClick={() =>
handleUpdateBlock(idx, {
...block,
items: [...block.items, { kind: 'all-photos' }],
})
}
>
<IconMap size={12} /> All geotagged photos
</button>
<label
style={{
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '0.8rem',
}}
>
<input
type="checkbox"
checked={block.line}
onChange={(e) => handleUpdateBlock(idx, { ...block, line: e.target.checked })}
/>
Connect pins with a line
</label>
</div>
<span style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }}>
Typed points are published as typed. Photo pins are placed from the photo&apos;s
GPS on the live page, under the album&apos;s location precision. Renders only
while the map is enabled in Settings.
</span>
</div>
)}
</SortableBlockCard>
))}
</SortableBlockList>
</div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -41,6 +41,8 @@ import {
} from './page-builder/types'; } from './page-builder/types';
import { useScrollLock } from './useScrollLock'; import { useScrollLock } from './useScrollLock';
import { useUnsavedGuard } from './useUnsavedGuard'; import { useUnsavedGuard } from './useUnsavedGuard';
import { useDraft } from './useDraft';
import DraftNotice from './DraftNotice';
import { reportIfSessionExpired } from './sessionExpiry'; import { reportIfSessionExpired } from './sessionExpiry';
import { import {
IconCamera, IconCamera,
@@ -273,6 +275,11 @@ export default function PageBuilder() {
useUnsavedGuard(dirty); useUnsavedGuard(dirty);
// 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);
const serverState = useRef<GalleryState | null>(null);
// A subpage opened from a `?album=` link can hold dozens of albums: bring the // 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 // linked one into view, and let the mark fade after a moment. The mark itself
// is a prop on the tile — a class added to the DOM here would be wiped by the // is a prop on the tile — a class added to the DOM here would be wiped by the
@@ -312,8 +319,11 @@ export default function PageBuilder() {
const { gallery: raw } = await galleryRes.json(); const { gallery: raw } = await galleryRes.json();
const parsed = parseGalleryYaml(raw); const parsed = parseGalleryYaml(raw);
setGallery(parsed); serverState.current = parsed;
openAlbumFromLink(parsed); const restored = draft.load(JSON.stringify(parsed));
setGallery(restored ?? parsed);
if (restored) setDirty(true);
openAlbumFromLink(restored ?? parsed);
// A failed album list is survivable — it only empties the picker, and // A failed album list is survivable — it only empties the picker, and
// saving with it empty changes nothing in gallery.yaml. // saving with it empty changes nothing in gallery.yaml.
@@ -418,6 +428,20 @@ export default function PageBuilder() {
return { hero, albums, subpages }; return { hero, albums, subpages };
} }
function discardDraft() {
draft.discard();
if (serverState.current) setGallery(serverState.current);
setDirty(false);
setSaveMessage('');
}
function restoreConflictingDraft() {
const value = draft.takeConflicting();
if (!value) return;
setGallery(value);
setDirty(true);
}
const markDirty = useCallback(() => { const markDirty = useCallback(() => {
setDirty(true); setDirty(true);
setSaveMessage(''); setSaveMessage('');
@@ -482,6 +506,13 @@ export default function PageBuilder() {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
// Fingerprint what the next load will see, the way it will see it: the
// file is exactly `yamlData`, read back through the same parser. The
// editor's own state can differ in shape (an `undefined` here, a
// default there) and would make every later draft look outdated.
const asLoaded = parseGalleryYaml(JSON.parse(JSON.stringify(yamlData)));
serverState.current = asLoaded;
draft.saved(JSON.stringify(asLoaded));
setDirty(false); setDirty(false);
setSaveMessage(data.message || 'Saved successfully!'); setSaveMessage(data.message || 'Saved successfully!');
setTimeout(() => setSaveMessage(''), 5000); setTimeout(() => setSaveMessage(''), 5000);
@@ -850,6 +881,14 @@ export default function PageBuilder() {
showPreview showPreview
/> />
<DraftNotice
status={draft.status}
subject="page structure"
onDiscard={discardDraft}
onRestore={restoreConflictingDraft}
onDismiss={draft.dismiss}
/>
{/* Search Bar */} {/* Search Bar */}
<div className="builder-search-container"> <div className="builder-search-container">
<div className="builder-search-wrapper"> <div className="builder-search-wrapper">

View File

@@ -0,0 +1,116 @@
'use client';
/**
* Drag-and-drop ordering for the block editors (Journal Studio and the
* PageBuilder's essay editor), on the same dnd-kit setup as the photo order
* editor. Blocks have no identity of their own and both editors key them by
* position, so the sortable ids are positions too; the editors apply the
* move to their block array and re-render.
*
* The grip is the only activator: the cards are full of inputs, and a
* pointer that starts on a text field must keep selecting text. The up/down
* buttons stay as the keyboard alternative; the grip also takes Space and
* the arrow keys through dnd-kit's keyboard sensor.
*/
import { useMemo, type ReactNode } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IconGripVertical } from './Icons';
const idAt = (index: number) => `block-${index}`;
interface SortableBlockListProps {
count: number;
onReorder: (from: number, to: number) => void;
children: ReactNode;
}
export function SortableBlockList({ count, onReorder, children }: SortableBlockListProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const ids = useMemo(() => Array.from({ length: count }, (_, i) => idAt(i)), [count]);
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
const from = ids.indexOf(String(active.id));
const to = ids.indexOf(String(over.id));
if (from < 0 || to < 0) return;
onReorder(from, to);
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{children}
</SortableContext>
</DndContext>
);
}
interface SortableBlockCardProps {
index: number;
badge: ReactNode;
actions: ReactNode;
children: ReactNode;
}
export function SortableBlockCard({ index, badge, actions, children }: SortableBlockCardProps) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: idAt(index) });
return (
<div
ref={setNodeRef}
className={`essay-block-card${isDragging ? ' is-dragging' : ''}`}
style={{
// Translate, not Transform: cards differ in height, and a scale would
// distort the one being carried past taller neighbours.
transform: CSS.Translate.toString(transform),
transition,
}}
>
<div className="essay-block-card-header">
<div className="essay-block-card-title">
<button
type="button"
ref={setActivatorNodeRef}
className="essay-block-grip"
aria-label="Drag to reorder"
title="Drag to reorder"
{...attributes}
{...listeners}
>
<IconGripVertical size={14} />
</button>
{badge}
</div>
{actions}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,684 @@
'use client';
import type { JournalBlock, MapItem } from '@/lib/journal';
import { isValidCoordinate } from '@/lib/journal';
import type { AlbumAssetRef } from '@/lib/journalAlbum';
import type AlbumPicker from '../AlbumPicker';
import {
IconCamera,
IconChevronUp,
IconChevronDown,
IconPlus,
IconMap,
IconFolder,
IconX,
} from '../Icons';
import { 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;
}
interface BlockFieldsProps {
block: JournalBlock;
onChange: (block: JournalBlock) => void;
onPickAsset: (target: AssetPickTarget) => void;
onPickAlbum: (onSelect: (albumId: string) => void) => void;
/** Assets of the albums referenced by album blocks, as loaded so far. */
albumAssets: Record<string, AlbumAssetRef[]>;
/** The picker's album list, once fetched — used for the album's name. */
albumList: Parameters<typeof AlbumPicker>[0]['albums'] | null;
mapEnabled?: boolean;
}
/**
* The form of one block in the journal editor, one branch per block type
* (#555). Every edit goes out through `onChange` as a whole new block; the
* editor owns the block list and its order.
*/
export function BlockFields({
block,
onChange,
onPickAsset,
onPickAlbum,
albumAssets,
albumList,
mapEnabled,
}: BlockFieldsProps) {
return (
<div style={{ marginTop: '0.75rem' }}>
{block.type === 'heading' && (
<div className="journal-heading-row">
<select
aria-label="Heading level"
className="admin-input journal-level-select"
value={block.level}
onChange={(e) => onChange({ ...block, level: Number(e.target.value) })}
>
<option value={1}>H1</option>
<option value={2}>H2</option>
<option value={3}>H3</option>
</select>
<input
type="text"
className="admin-input"
value={block.text}
onChange={(e) => onChange({ ...block, text: e.target.value })}
/>
</div>
)}
{block.type === 'paragraph' && (
<textarea
className="admin-input"
rows={3}
value={block.html}
onChange={(e) => onChange({ ...block, html: e.target.value })}
/>
)}
{block.type === 'quote' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<textarea
className="admin-input"
rows={2}
value={block.text}
placeholder="Quote text..."
onChange={(e) => onChange({ ...block, text: e.target.value })}
/>
<input
type="text"
className="admin-input"
value={block.author || ''}
placeholder="Author attribution (optional)"
onChange={(e) => onChange({ ...block, author: e.target.value })}
/>
</div>
)}
{block.type === 'photo' && isLegacyAssetRef(block.assetId) && (
<p className="journal-block-warning">
Reference &quot;{block.assetId}&quot; is a legacy album position, not a photo. It will not
appear on the published page — pick a photo below.
</p>
)}
{block.type === 'photo' && (
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<div
style={{
width: '140px',
height: '96px',
flexShrink: 0,
background: 'rgba(0,0,0,0.3)',
borderRadius: '6px',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
onClick={() =>
onPickAsset({
title: 'Select Photo for Story',
onSelect: (id) => onChange({ ...block, assetId: id }),
})
}
>
{block.assetId ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/admin/thumbnail/${block.assetId}`}
alt="Thumb"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span style={{ fontSize: '0.8rem', opacity: 0.6 }}>+ Pick</span>
)}
</div>
<div
style={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
<div className="journal-photo-layout-row">
<select
aria-label="Photo layout"
className="admin-input"
value={block.layout}
onChange={(e) =>
onChange({
...block,
layout: e.target.value as 'contained' | 'wide' | 'fullbleed',
})
}
>
<option value="contained">Contained (Column Width)</option>
<option value="wide">Wide (Expanded Width)</option>
<option value="fullbleed">Fullbleed (Edge to Edge)</option>
</select>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
onPickAsset({
title: 'Select Photo for Story',
onSelect: (id) => onChange({ ...block, assetId: id }),
})
}
>
Change Photo
</button>
</div>
<input
type="text"
className="admin-input"
placeholder="Caption (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
/>
</div>
</div>
)}
{block.type === 'photo-pair' && block.assetIds.some(isLegacyAssetRef) && (
<p className="journal-block-warning">
References &quot;{block.assetIds.filter(isLegacyAssetRef).join('", "')}
&quot; are legacy album positions, not photos. They will not appear on the published page
— pick photos below.
</p>
)}
{block.type === 'photo-pair' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1rem',
}}
>
{[0, 1].map((pIdx) => (
<div
key={pIdx}
style={{
height: '90px',
background: 'rgba(0,0,0,0.3)',
borderRadius: '6px',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
border: '1px dashed rgba(255,255,255,0.15)',
}}
onClick={() =>
onPickAsset({
title: `Select Photo #${pIdx + 1} for Pair`,
onSelect: (id) => {
const newIds = [...block.assetIds] as [string, string];
newIds[pIdx] = id;
onChange({ ...block, assetIds: newIds });
},
})
}
>
{block.assetIds[pIdx] ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/admin/thumbnail/${block.assetIds[pIdx]}`}
alt="Thumb"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span style={{ fontSize: '0.8rem', opacity: 0.6 }}>+ Pick Photo #{pIdx + 1}</span>
)}
</div>
))}
</div>
<input
type="text"
className="admin-input"
placeholder="Shared caption for pair (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
/>
</div>
)}
{block.type === 'photo-grid' && block.assetIds.some(isLegacyAssetRef) && (
<p className="journal-block-warning">
References &quot;{block.assetIds.filter(isLegacyAssetRef).join('", "')}
&quot; are legacy album positions, not photos. They will not appear on the published page
— pick photos below.
</p>
)}
{block.type === 'photo-grid' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div className="journal-grid-tiles">
{block.assetIds.map((assetId, pIdx) => (
<div key={pIdx} className="journal-grid-tile">
<div
className="journal-grid-tile-pick"
onClick={() =>
onPickAsset({
title: `Select Photo #${pIdx + 1} for Grid`,
onSelect: (id) => {
const newIds = [...block.assetIds];
newIds[pIdx] = id;
onChange({ ...block, assetIds: newIds });
},
})
}
>
{assetId ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={`/api/admin/thumbnail/${assetId}`} alt="Thumb" />
) : (
<span>+ Pick #{pIdx + 1}</span>
)}
</div>
{block.assetIds.length > 3 && (
<button
type="button"
className="admin-btn admin-btn-xs journal-grid-tile-remove"
aria-label={`Remove photo #${pIdx + 1}`}
onClick={() =>
onChange({
...block,
assetIds: block.assetIds.filter((_, i) => i !== pIdx),
})
}
>
<IconX size={11} />
</button>
)}
</div>
))}
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
onChange({
...block,
assetIds: [...block.assetIds, ''],
})
}
>
<IconPlus size={12} /> Add photo
</button>
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
Three or more photos, laid out in rows of three.
</span>
</div>
<input
type="text"
className="admin-input"
placeholder="Shared caption for grid (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
/>
</div>
)}
{block.type === 'facts' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{block.items.map((item, fIdx) => (
<div key={fIdx} className="journal-facts-row">
<input
type="text"
className="admin-input"
placeholder="Label, e.g. Distance"
value={item.label}
onChange={(e) =>
onChange({
...block,
items: block.items.map((it, i) =>
i === fIdx ? { ...it, label: e.target.value } : it,
),
})
}
/>
<input
type="text"
className="admin-input"
placeholder="Value, e.g. 21 km"
value={item.value}
onChange={(e) =>
onChange({
...block,
items: block.items.map((it, i) =>
i === fIdx ? { ...it, value: e.target.value } : it,
),
})
}
/>
<button
type="button"
className="admin-btn admin-btn-xs"
aria-label={`Remove fact #${fIdx + 1}`}
disabled={block.items.length <= 1}
onClick={() =>
onChange({
...block,
items: block.items.filter((_, i) => i !== fIdx),
})
}
>
<IconX size={11} />
</button>
</div>
))}
<div>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
onChange({
...block,
items: [...block.items, { label: '', value: '' }],
})
}
>
<IconPlus size={12} /> Add fact
</button>
</div>
</div>
)}
{block.type === 'album' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div className="journal-album-row">
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-primary"
onClick={() => onPickAlbum((albumId) => onChange({ ...block, albumId }))}
>
<IconFolder size={12} />{' '}
{block.albumId
? (albumList?.find((a) => a.id === block.albumId)?.albumName ?? 'Change album')
: 'Pick album'}
</button>
{block.albumId && (
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
{albumAssets[block.albumId]
? `${albumAssets[block.albumId].length} photos in album`
: 'loading…'}
</span>
)}
</div>
<div className="journal-album-row">
<label className="journal-album-field">
Count
<input
type="number"
min={1}
className="admin-input"
placeholder="all"
value={block.count ?? ''}
onChange={(e) =>
onChange({
...block,
count: e.target.value ? Number(e.target.value) : undefined,
})
}
/>
</label>
<label className="journal-album-field">
Skip
<input
type="number"
min={0}
className="admin-input"
placeholder="0"
value={block.skip ?? ''}
onChange={(e) =>
onChange({
...block,
skip: e.target.value ? Number(e.target.value) : undefined,
})
}
/>
</label>
<label className="journal-album-field">
Layout
<select
className="admin-input"
value={block.layout}
onChange={(e) =>
onChange({
...block,
layout: e.target.value as 'grid' | 'pairs' | 'wide',
})
}
>
<option value="grid">Grid (rows of three)</option>
<option value="pairs">Pairs (rows of two)</option>
<option value="wide">Wide (one per row)</option>
</select>
</label>
</div>
<input
type="text"
className="admin-input"
placeholder="Caption for the set (optional)"
value={block.caption || ''}
onChange={(e) =>
onChange({
...block,
caption: e.target.value || undefined,
})
}
/>
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
Photos follow the album&apos;s order (a manual order in the gallery comes first). Count
and skip pick a slice; leave count empty for all.
</span>
</div>
)}
{block.type === 'map' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{mapEnabled === false && (
<p className="journal-block-warning">
The map is switched off in Settings → General. This block will not render until it is
enabled.
</p>
)}
<input
type="text"
className="admin-input"
placeholder="Caption, e.g. Busan → Seoul (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
/>
{block.items.map((item, mIdx) => {
const setItem = (next: MapItem) =>
onChange({
...block,
items: block.items.map((it, i) => (i === mIdx ? next : it)),
});
const move = (dir: -1 | 1) => {
const items = [...block.items];
const target = mIdx + dir;
if (target < 0 || target >= items.length) return;
[items[mIdx], items[target]] = [items[target], items[mIdx]];
onChange({ ...block, items });
};
return (
<div key={mIdx} className="journal-map-row">
<span className="journal-map-index">{mIdx + 1}</span>
{item.kind === 'point' && (
<>
<input
type="text"
className="admin-input"
placeholder="Label (optional)"
value={item.label ?? ''}
onChange={(e) => setItem({ ...item, label: e.target.value || undefined })}
/>
<input
type="number"
step="any"
className="admin-input journal-map-coord"
placeholder="Lat"
aria-label="Latitude"
value={Number.isFinite(item.lat) ? item.lat : ''}
onChange={(e) => setItem({ ...item, lat: parseFloat(e.target.value) })}
/>
<input
type="number"
step="any"
className="admin-input journal-map-coord"
placeholder="Lng"
aria-label="Longitude"
value={Number.isFinite(item.lng) ? item.lng : ''}
onChange={(e) => setItem({ ...item, lng: parseFloat(e.target.value) })}
/>
</>
)}
{item.kind === 'photo' && (
<div
className="journal-map-photo"
onClick={() =>
onPickAsset({
title: 'Select a photo to place on the map',
onSelect: (id) => setItem({ kind: 'photo', assetId: id }),
})
}
>
{item.assetId ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={`/api/admin/thumbnail/${item.assetId}`} alt="Thumb" />
) : (
<span>+ Pick photo</span>
)}
</div>
)}
{item.kind === 'all-photos' && (
<span className="journal-map-all">
All geotagged photos of this entry, in order
</span>
)}
<div className="essay-block-actions">
<button
type="button"
className="admin-btn admin-btn-xs"
disabled={mIdx === 0}
aria-label="Move pin up"
onClick={() => move(-1)}
>
<IconChevronUp size={12} />
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
disabled={mIdx === block.items.length - 1}
aria-label="Move pin down"
onClick={() => move(1)}
>
<IconChevronDown size={12} />
</button>
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-danger"
aria-label="Remove pin"
onClick={() =>
onChange({
...block,
items: block.items.filter((_, i) => i !== mIdx),
})
}
>
<IconX size={11} />
</button>
</div>
</div>
);
})}
{block.items.some((it) => it.kind === 'point' && !isValidCoordinate(it.lat, it.lng)) && (
<p className="journal-block-warning">
A point needs a latitude within ±90 and a longitude within ±180. Points without valid
coordinates are not saved.
</p>
)}
<div
style={{
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
alignItems: 'center',
}}
>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
onChange({
...block,
items: [...block.items, { kind: 'point', lat: Number.NaN, lng: Number.NaN }],
})
}
>
<IconPlus size={12} /> Point
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
onPickAsset({
title: 'Select a photo to place on the map',
onSelect: (id) =>
onChange({
...block,
items: [...block.items, { kind: 'photo', assetId: id }],
}),
})
}
>
<IconCamera size={12} /> Photo pin
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
disabled={block.items.some((it) => it.kind === 'all-photos')}
onClick={() =>
onChange({
...block,
items: [...block.items, { kind: 'all-photos' }],
})
}
>
<IconMap size={12} /> All geotagged photos
</button>
<label className="journal-map-line-toggle">
<input
type="checkbox"
checked={block.line}
onChange={(e) => onChange({ ...block, line: e.target.checked })}
/>
Connect pins with a line
</label>
</div>
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
Typed points show in the preview right away. Photo pins are placed on the live page from
the photo&apos;s GPS, under the album&apos;s location precision.
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,607 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import type { ParsedJournal, JournalBlock } from '@/lib/journal';
import { parseJournalMarkdown, serializeJournalMarkdown, collectAssetIds } from '@/lib/journal';
import {
IconFileText,
IconSparkles,
IconQuote,
IconCamera,
IconArrowLeftRight,
IconTrash,
IconChevronUp,
IconChevronDown,
IconGear,
IconLink,
IconCheck,
IconGrid,
IconColumns,
IconMap,
IconFolder,
} from '../Icons';
import AssetPicker from '../AssetPicker';
import AlbumPicker from '../AlbumPicker';
import { SortableBlockList, SortableBlockCard } from '../SortableBlocks';
import { arrayMove } from '@dnd-kit/sortable';
import type { AlbumAssetRef } from '@/lib/journalAlbum';
import { BlockBadge } from '../BlockBadge';
import { useUnsavedGuard } from '../useUnsavedGuard';
import { useDraft } from '../useDraft';
import DraftNotice from '../DraftNotice';
import { reportIfSessionExpired } from '../sessionExpiry';
import './journal-studio.css';
import { BlockFields, type AssetPickTarget } from './BlockFields';
import { StorySettingsModal } from './StorySettingsModal';
import { JournalPreview } from './JournalPreview';
import { createBlock, moveBlock } from './blockOps';
import { useSplitPane, SPLIT_MIN, SPLIT_MAX } from './splitPane';
interface JournalEditorProps {
slug: string;
mapEnabled?: boolean;
onBack: () => void;
}
export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [editorMode, setEditorMode] = useState<'blocks' | 'markdown'>('blocks');
// Draggable divider between authoring pane and preview.
const {
splitRef,
splitPct,
dragging,
onResizerMouseDown,
onResizerDoubleClick,
onResizerKeyDown,
} = useSplitPane();
const [rawMarkdown, setRawMarkdown] = useState('');
const [parsed, setParsed] = useState<ParsedJournal>(() => ({
frontmatter: {},
blocks: [],
referencedAssetIds: [],
}));
// Settings / Metadata Modal
const [showMetaModal, setShowMetaModal] = useState(false);
// Asset Picker State
const [assetPickerTarget, setAssetPickerTarget] = useState<AssetPickTarget | null>(null);
/** Set when the entry could not be fetched; blocks saving over it. */
const [loadError, setLoadError] = useState<string | null>(null);
// 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 loadDraft = draft.load;
const serverMarkdown = useRef('');
// Load entry
useEffect(() => {
async function load() {
setLoading(true);
setLoadError(null);
try {
const res = await fetch(`/api/admin/journal/${slug}`);
if (!res.ok) {
throw new Error(
res.status === 401
? 'Your session has expired. Sign in again to continue.'
: `The server answered ${res.status}.`,
);
}
const data = await res.json();
const md: string = data.entry.rawMarkdown;
serverMarkdown.current = md;
const restored = loadDraft(md);
setRawMarkdown(restored ?? md);
setParsed(parseJournalMarkdown(restored ?? md));
setDirty(restored !== null);
} catch (err) {
console.error('Failed to load journal entry:', err);
// An empty editor saved over the entry replaces it with nothing, so
// this blocks the save rather than only reporting it.
setLoadError(err instanceof Error ? err.message : 'The entry could not be loaded.');
} finally {
setLoading(false);
}
}
load();
}, [slug, loadDraft]);
// Update markdown and sync blocks
const handleMarkdownChange = (newMd: string) => {
setRawMarkdown(newMd);
setParsed(parseJournalMarkdown(newMd));
setDirty(true);
};
// Update structured blocks and sync markdown
const handleBlocksChange = (newBlocks: JournalBlock[]) => {
const updated: ParsedJournal = {
...parsed,
blocks: newBlocks,
referencedAssetIds: collectAssetIds(newBlocks),
};
const serialized = serializeJournalMarkdown(updated);
setParsed(updated);
setRawMarkdown(serialized);
setDirty(true);
};
// Update frontmatter
const handleFrontmatterChange = (updates: Partial<ParsedJournal['frontmatter']>) => {
const updated: ParsedJournal = {
...parsed,
frontmatter: {
...parsed.frontmatter,
...updates,
},
};
const serialized = serializeJournalMarkdown(updated);
setParsed(updated);
setRawMarkdown(serialized);
setDirty(true);
};
// Save
const handleSave = async () => {
// The editor is not rendered in this state, but Cmd+S still reaches here.
if (loadError) return;
setSaving(true);
try {
const res = await fetch(`/api/admin/journal/${slug}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rawMarkdown,
}),
});
if (res.ok) {
serverMarkdown.current = rawMarkdown;
draft.saved(rawMarkdown);
setDirty(false);
} else if (!reportIfSessionExpired(res)) {
const data = await res.json();
alert(data.error || 'Failed to save');
}
} catch {
alert('Error saving entry');
} finally {
setSaving(false);
}
};
useUnsavedGuard(dirty);
const discardDraft = () => {
draft.discard();
setRawMarkdown(serverMarkdown.current);
setParsed(parseJournalMarkdown(serverMarkdown.current));
setDirty(false);
};
const restoreConflictingDraft = () => {
const value = draft.takeConflicting();
if (value === null) return;
setRawMarkdown(value);
setParsed(parseJournalMarkdown(value));
setDirty(true);
};
// Keyboard shortcut: Cmd+S / Ctrl+S
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [handleSave]); // eslint-disable-line react-hooks/exhaustive-deps
/*
* Album blocks are expanded for the preview the way the page expands them,
* from the admin assets route; until an album has loaded its block simply
* does not render. The picker's album list is fetched on first use.
*/
const [albumAssets, setAlbumAssets] = useState<Record<string, AlbumAssetRef[]>>({});
const albumRequested = useRef(new Set<string>());
const [albumList, setAlbumList] = useState<Parameters<typeof AlbumPicker>[0]['albums'] | null>(
null,
);
const [albumPickerTarget, setAlbumPickerTarget] = useState<((albumId: string) => void) | null>(
null,
);
useEffect(() => {
let cancelled = false;
for (const block of parsed.blocks) {
if (block.type !== 'album' || !block.albumId || albumRequested.current.has(block.albumId))
continue;
const albumId = block.albumId;
albumRequested.current.add(albumId);
fetch(`/api/admin/albums/${albumId}/assets?types=all`)
.then((res) => (res.ok ? res.json() : Promise.reject(new Error(String(res.status)))))
.then((data: { assets: AlbumAssetRef[] }) => {
if (!cancelled) setAlbumAssets((prev) => ({ ...prev, [albumId]: data.assets }));
})
.catch(() => {
// Let a retry happen after a save or a re-pick.
albumRequested.current.delete(albumId);
});
}
return () => {
cancelled = true;
};
}, [parsed.blocks]);
const openAlbumPicker = (onSelect: (albumId: string) => void) => {
setAlbumPickerTarget(() => onSelect);
if (albumList === null) {
fetch('/api/admin/albums')
.then((res) => res.json())
.then((data) => setAlbumList(data.albums ?? []))
.catch(() => setAlbumList([]));
}
};
// Block manipulation
const handleAddBlock = (type: JournalBlock['type']) => {
handleBlocksChange([...parsed.blocks, createBlock(type)]);
};
const handleMoveBlock = (index: number, direction: 'up' | 'down') => {
const blocks = moveBlock(parsed.blocks, index, direction);
if (blocks) handleBlocksChange(blocks);
};
const handleReorderBlock = (from: number, to: number) => {
handleBlocksChange(arrayMove(parsed.blocks, from, to));
};
const handleDeleteBlock = (index: number) => {
handleBlocksChange(parsed.blocks.filter((_, i) => i !== index));
};
const handleUpdateBlock = (index: number, updated: JournalBlock) => {
const blocks = [...parsed.blocks];
blocks[index] = updated;
handleBlocksChange(blocks);
};
if (loading) {
return (
<div style={{ padding: '4rem', textAlign: 'center', opacity: 0.6 }}>
Opening Journal Studio...
</div>
);
}
/**
* Replaces the editor rather than sitting above it: an empty editor saved
* over the entry would replace the text with nothing.
*/
if (loadError) {
return (
<div className="admin-error" role="alert">
<strong>This entry could not be loaded.</strong> {loadError}
<p>
Nothing has been changed. Saving stays disabled until it loads, so an empty editor cannot
overwrite the entry.
</p>
<button className="admin-btn admin-btn-secondary" onClick={onBack}>
Back to entries
</button>
</div>
);
}
return (
<div className="journal-editor-container">
{/* Top Bar */}
<div className="journal-editor-topbar">
<div className="journal-editor-topbar-left">
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-secondary"
onClick={onBack}
>
← All Entries
</button>
<input
type="text"
className="journal-editor-title-input"
value={parsed.frontmatter.title || ''}
placeholder="Story Title..."
onChange={(e) => handleFrontmatterChange({ title: e.target.value })}
/>
<div
style={{
display: 'flex',
gap: '4px',
background: 'rgba(255,255,255,0.06)',
borderRadius: '6px',
padding: '2px',
}}
>
<button
type="button"
className={`admin-btn admin-btn-xs ${editorMode === 'blocks' ? 'admin-btn-primary' : ''}`}
onClick={() => setEditorMode('blocks')}
>
Visual Blocks
</button>
<button
type="button"
className={`admin-btn admin-btn-xs ${editorMode === 'markdown' ? 'admin-btn-primary' : ''}`}
onClick={() => setEditorMode('markdown')}
>
Raw Markdown
</button>
</div>
</div>
<div className="journal-editor-topbar-right">
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-secondary"
onClick={() => setShowMetaModal(true)}
>
<IconGear size={14} /> Story Settings
</button>
<a
href={`/journal/${slug}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-sm admin-btn-secondary"
>
<IconLink size={14} /> Live
</a>
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-primary"
onClick={handleSave}
disabled={saving}
>
{saving ? (
'Saving...'
) : dirty ? (
'Save Changes'
) : (
<>
<IconCheck size={14} /> Saved
</>
)}
</button>
</div>
</div>
<DraftNotice
status={draft.status}
subject="entry"
onDiscard={discardDraft}
onRestore={restoreConflictingDraft}
onDismiss={draft.dismiss}
/>
{/* Split Screen */}
<div
className={`journal-editor-split${dragging ? ' is-dragging' : ''}`}
ref={splitRef}
style={{ ['--journal-split-left' as string]: `${splitPct}%` }}
>
{/* Left: Authoring Pane */}
<div className="journal-editor-pane-left">
{editorMode === 'markdown' ? (
<textarea
className="journal-raw-markdown-editor"
value={rawMarkdown}
onChange={(e) => handleMarkdownChange(e.target.value)}
placeholder="Write Markdown here..."
/>
) : (
<div>
{/* Add Block Toolbar */}
<div className="essay-toolbar-sticky">
<span className="essay-toolbar-label">Add Block:</span>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() => handleAddBlock('paragraph')}
>
<IconFileText size={13} /> + Text
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() => handleAddBlock('heading')}
>
<IconSparkles size={13} /> + Heading
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() => handleAddBlock('quote')}
>
<IconQuote size={13} /> + Quote
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() => handleAddBlock('facts')}
>
<IconColumns size={13} /> + Facts
</button>
<div className="essay-toolbar-divider" />
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-primary"
onClick={() => handleAddBlock('photo')}
>
<IconCamera size={13} /> + Photo
</button>
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-primary"
onClick={() => handleAddBlock('photo-pair')}
>
<IconArrowLeftRight size={13} /> + 2-Photo Pair
</button>
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-primary"
onClick={() => handleAddBlock('photo-grid')}
>
<IconGrid size={13} /> + Photo Grid
</button>
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-primary"
onClick={() => handleAddBlock('album')}
>
<IconFolder size={13} /> + Album
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() => handleAddBlock('map')}
>
<IconMap size={13} /> + Map
</button>
</div>
{/* Blocks List */}
<SortableBlockList count={parsed.blocks.length} onReorder={handleReorderBlock}>
<div
style={{
marginTop: '1rem',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}}
>
{parsed.blocks.map((block, idx) => (
<SortableBlockCard
key={idx}
index={idx}
badge={<BlockBadge type={block.type} />}
actions={
<div className="essay-block-actions">
<button
type="button"
className="admin-btn admin-btn-xs"
disabled={idx === 0}
aria-label="Move block up"
onClick={() => handleMoveBlock(idx, 'up')}
>
<IconChevronUp size={12} />
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
disabled={idx === parsed.blocks.length - 1}
aria-label="Move block down"
onClick={() => handleMoveBlock(idx, 'down')}
>
<IconChevronDown size={12} />
</button>
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-danger"
aria-label="Delete block"
onClick={() => handleDeleteBlock(idx)}
>
<IconTrash size={12} />
</button>
</div>
}
>
<BlockFields
block={block}
onChange={(updated) => handleUpdateBlock(idx, updated)}
onPickAsset={setAssetPickerTarget}
onPickAlbum={openAlbumPicker}
albumAssets={albumAssets}
albumList={albumList}
mapEnabled={mapEnabled}
/>
</SortableBlockCard>
))}
</div>
</SortableBlockList>
</div>
)}
</div>
{/* Draggable divider */}
<div
className="journal-editor-resizer"
role="separator"
aria-orientation="vertical"
aria-label="Resize editor and preview"
aria-valuenow={Math.round(splitPct)}
aria-valuemin={SPLIT_MIN}
aria-valuemax={SPLIT_MAX}
tabIndex={0}
onMouseDown={onResizerMouseDown}
onDoubleClick={onResizerDoubleClick}
onKeyDown={onResizerKeyDown}
>
<span className="journal-editor-resizer-grip" aria-hidden="true" />
</div>
{/* Right: Live Preview Pane */}
<JournalPreview parsed={parsed} albumAssets={albumAssets} />
</div>
{/* Metadata Modal */}
{showMetaModal && (
<StorySettingsModal
frontmatter={parsed.frontmatter}
onChange={handleFrontmatterChange}
onPickAsset={setAssetPickerTarget}
onClose={() => setShowMetaModal(false)}
/>
)}
{/* Album Picker Modal */}
{albumPickerTarget && albumList !== null && (
<AlbumPicker
albums={albumList}
usedAlbumIds={new Set()}
onSelect={(albumId) => {
albumPickerTarget(albumId);
setAlbumPickerTarget(null);
}}
onClose={() => setAlbumPickerTarget(null)}
/>
)}
{/* Asset Picker Modal */}
{assetPickerTarget && (
<AssetPicker
title={assetPickerTarget.title}
onSelect={(id) => {
assetPickerTarget.onSelect(id);
setAssetPickerTarget(null);
}}
onClose={() => setAssetPickerTarget(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ParsedJournal } from '@/lib/journal';
import { collectAssetIds } from '@/lib/journal';
import { expandAlbumBlocks, type AlbumAssetRef } from '@/lib/journalAlbum';
import { EssayView } from '@/app/[...path]/EssayView';
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
import { IconEye } from '../Icons';
interface JournalPreviewProps {
parsed: ParsedJournal;
/** Assets of the albums referenced by album blocks, as loaded so far. */
albumAssets: Record<string, AlbumAssetRef[]>;
}
/**
* The editor's live preview: the entry rendered through the public
* `EssayView`, with a desktop/mobile frame (#555). Album blocks are expanded
* the way the page expands them, and photo proportions are measured from the
* admin thumbnails.
*/
export function JournalPreview({ parsed, albumAssets }: JournalPreviewProps) {
const [viewport, setViewport] = useState<'desktop' | 'mobile'>('desktop');
/** Real width/height ratios of the referenced photos, measured from thumbnails. */
const [assetRatios, setAssetRatios] = useState<Record<string, number>>({});
const previewBlocks = useMemo(
() => expandAlbumBlocks(parsed.blocks, (id) => albumAssets[id]).blocks,
[parsed.blocks, albumAssets],
);
const previewIds = useMemo(
() =>
Array.from(
new Set([
...(parsed.frontmatter.coverAssetId ? [parsed.frontmatter.coverAssetId] : []),
...collectAssetIds(previewBlocks),
]),
),
[previewBlocks, parsed.frontmatter.coverAssetId],
);
/*
* The preview used to hard-code 3:2 for every photo, so the studio showed a
* cropped, uniform grid while the published page laid the photos out by their
* real proportions. There is no admin endpoint that reports asset dimensions,
* so the thumbnails are measured once as they load.
*/
const measuredRef = useRef(new Set<string>());
useEffect(() => {
let cancelled = false;
for (const id of previewIds) {
if (!id || measuredRef.current.has(id)) continue;
measuredRef.current.add(id);
const probe = new window.Image();
probe.onload = () => {
if (cancelled || !probe.naturalHeight) return;
setAssetRatios((prev) => ({
...prev,
[id]: probe.naturalWidth / probe.naturalHeight,
}));
};
probe.onerror = () => {
// Unresolvable reference — keep the fallback ratio, the block editor
// already flags it.
measuredRef.current.delete(id);
};
probe.src = `/api/admin/thumbnail/${id}`;
}
return () => {
cancelled = true;
};
}, [previewIds]);
// Mock PhotoItems for preview
const previewAssets: PhotoItem[] = previewIds.map((id) => ({
id,
type: 'image',
thumbUrl: `/api/admin/thumbnail/${id}`,
previewUrl: `/api/admin/thumbnail/${id}`,
exifUrl: `/api/exif/${id}`,
// Falls back to 3:2 only until the real ratio has been measured.
aspectRatio: assetRatios[id] ?? 1.5,
}));
return (
<div className="journal-editor-pane-right">
<div className="journal-preview-bar">
<span className="journal-preview-bar-label">
<IconEye size={13} /> Realtime Theme Preview
</span>
<div style={{ display: 'flex', gap: '4px' }}>
<button
type="button"
className={`admin-btn admin-btn-xs ${viewport === 'desktop' ? 'admin-btn-primary' : ''}`}
onClick={() => setViewport('desktop')}
>
Desktop
</button>
<button
type="button"
className={`admin-btn admin-btn-xs ${viewport === 'mobile' ? 'admin-btn-primary' : ''}`}
onClick={() => setViewport('mobile')}
>
Mobile
</button>
</div>
</div>
<div className={`journal-preview-frame ${viewport}`}>
<EssayView
essay={{ ...parsed, blocks: previewBlocks }}
assets={previewAssets}
title={parsed.frontmatter.title}
subtitle={parsed.frontmatter.subtitle}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,382 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import type { JournalEntrySummary } from '@/lib/journal';
import { serializeJournalMarkdown, sanitizeSlug } from '@/lib/journal';
import { JOURNAL_TEMPLATES } from '@/lib/journalTemplates';
import {
IconTrash,
IconLink,
IconPlus,
IconBook,
IconClock,
IconCalendar,
IconLock,
} from '../Icons';
import './journal-studio.css';
import { JournalEditor } from './JournalEditor';
interface JournalStudioProps {
/** Entry to open, taken from the /admin/journal/[slug] route. */
slug?: string;
/** `config.map`, passed by the server route: a map block renders only when the site has a map. */
mapEnabled?: boolean;
}
export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioProps) {
const router = useRouter();
const [entries, setEntries] = useState<JournalEntrySummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// New Entry Modal
const [showCreateModal, setShowCreateModal] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newSlug, setNewSlug] = useState('');
const [creating, setCreating] = useState(false);
/** null = start blank, matching the previous (only) behavior. */
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const fetchEntries = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/journal');
if (res.ok) {
const data = await res.json();
setEntries(data.entries || []);
} else {
setError('Failed to load journal entries');
}
} catch {
setError('Failed to load journal entries');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchEntries();
}, [fetchEntries]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTitle.trim()) return;
setCreating(true);
try {
const slug = sanitizeSlug(newSlug || newTitle);
const template = selectedTemplateId
? JOURNAL_TEMPLATES.find((t) => t.id === selectedTemplateId)
: undefined;
const content = template
? serializeJournalMarkdown({
frontmatter: {
title: newTitle.trim(),
date: new Date().toISOString().slice(0, 10),
draft: true,
},
blocks: template.blocks,
referencedAssetIds: [],
})
: undefined;
const res = await fetch('/api/admin/journal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: newTitle.trim(),
slug,
...(content ? { content } : {}),
}),
});
if (res.ok) {
const data = await res.json();
setShowCreateModal(false);
setNewTitle('');
setNewSlug('');
setSelectedTemplateId(null);
await fetchEntries();
if (data.entry?.slug) {
router.push(`/admin/journal/${data.entry.slug}`);
}
} else {
const data = await res.json();
alert(data.error || 'Failed to create journal entry');
}
} catch {
alert('Error creating journal entry');
} finally {
setCreating(false);
}
};
const handleDelete = async (slug: string) => {
if (
!confirm(
`Delete "${slug}"?\n\nA copy is kept and can be restored from Backups on the dashboard.`,
)
)
return;
try {
const res = await fetch(`/api/admin/journal/${slug}`, {
method: 'DELETE',
});
if (res.ok) {
await fetchEntries();
if (activeSlug === slug) router.push('/admin/journal');
} else {
alert('Failed to delete journal entry');
}
} catch {
alert('Error deleting journal entry');
}
};
if (activeSlug) {
return (
<JournalEditor
slug={activeSlug}
mapEnabled={mapEnabled}
onBack={() => router.push('/admin/journal')}
/>
);
}
return (
<div className="journal-studio">
<div className="journal-studio-header">
<div>
<h2>
<IconBook size={20} /> Journal &amp; Photo Essays
</h2>
<p style={{ margin: '4px 0 0', opacity: 0.7, fontSize: '0.9rem' }}>
Author visual stories, field notes, and longform photo essays with live preview.
</p>
</div>
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={() => setShowCreateModal(true)}
>
<IconPlus size={16} /> New Journal Entry
</button>
</div>
{loading ? (
<div style={{ padding: '3rem', textAlign: 'center', opacity: 0.6 }}>
Loading journal entries...
</div>
) : error ? (
<div style={{ padding: '3rem', textAlign: 'center', color: '#ef4444' }}>{error}</div>
) : entries.length === 0 ? (
<div style={{ padding: '5rem 2rem', textAlign: 'center', opacity: 0.6 }}>
<h3>No journal entries yet</h3>
<p style={{ margin: '8px 0 1.5rem', fontSize: '0.9rem' }}>
Create your first story to share travel journals or wedding reportages.
</p>
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={() => setShowCreateModal(true)}
>
<IconPlus size={16} /> Create First Entry
</button>
</div>
) : (
<div className="journal-list-grid">
{entries.map((entry) => (
<div key={entry.slug} className="journal-admin-card">
<div className="journal-admin-card-cover">
{entry.frontmatter.coverAssetId ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/admin/thumbnail/${entry.frontmatter.coverAssetId}`}
alt={entry.frontmatter.title || entry.slug}
/>
) : (
<IconBook size={36} className="svg-icon journal-card-cover-placeholder" />
)}
</div>
<div className="journal-admin-card-body">
<h3 className="journal-admin-card-title">
{entry.frontmatter.title || entry.slug}
</h3>
<div className="journal-admin-card-slug">/journal/{entry.slug}</div>
<div className="journal-admin-card-meta">
{entry.frontmatter.date && (
<span>
<IconCalendar size={12} /> {entry.frontmatter.date}
</span>
)}
<span>
<IconClock size={12} /> {entry.readingTimeMinutes} min
</span>
{entry.frontmatter.draft ? (
<span className="journal-status-pill is-draft">Draft</span>
) : (
<span className="journal-status-pill is-published">Published</span>
)}
{entry.frontmatter.password && (
<span>
<IconLock size={12} /> Password
</span>
)}
</div>
<div className="journal-admin-card-actions">
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-primary"
onClick={() => router.push(`/admin/journal/${entry.slug}`)}
>
Edit in Studio
</button>
<a
href={`/journal/${entry.slug}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-sm admin-btn-secondary"
title="View live page"
>
<IconLink size={14} />
</a>
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-danger"
onClick={() => handleDelete(entry.slug)}
title="Delete entry"
style={{ marginLeft: 'auto' }}
>
<IconTrash size={14} />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Create Modal */}
{showCreateModal && (
<div className="journal-modal-overlay">
<div className="journal-modal-card">
<h3 style={{ margin: '0 0 1rem' }}>Create New Journal Entry</h3>
<form onSubmit={handleCreate}>
<div style={{ marginBottom: '1rem' }}>
<label
style={{
display: 'block',
fontSize: '0.85rem',
marginBottom: '4px',
opacity: 0.8,
}}
>
Title
</label>
<input
type="text"
className="admin-input"
placeholder="e.g. Expedition Nordkap"
value={newTitle}
onChange={(e) => {
setNewTitle(e.target.value);
if (!newSlug || newSlug === sanitizeSlug(newTitle)) {
setNewSlug(sanitizeSlug(e.target.value));
}
}}
autoFocus
required
/>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label
style={{
display: 'block',
fontSize: '0.85rem',
marginBottom: '4px',
opacity: 0.8,
}}
>
URL Slug
</label>
<input
type="text"
className="admin-input"
placeholder="e.g. expedition-nordkap"
value={newSlug}
onChange={(e) => setNewSlug(sanitizeSlug(e.target.value))}
required
/>
<span
style={{ fontSize: '0.75rem', opacity: 0.6, marginTop: '2px', display: 'block' }}
>
Will be accessible at /journal/{newSlug || 'slug'}
</span>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label
style={{
display: 'block',
fontSize: '0.85rem',
marginBottom: '6px',
opacity: 0.8,
}}
>
Start from
</label>
<div className="journal-template-grid">
<button
type="button"
className={`journal-template-card${selectedTemplateId === null ? ' is-selected' : ''}`}
onClick={() => setSelectedTemplateId(null)}
>
<span className="journal-template-card-name">Blank</span>
<span className="journal-template-card-desc">Start with an empty editor.</span>
</button>
{JOURNAL_TEMPLATES.map((template) => (
<button
key={template.id}
type="button"
className={`journal-template-card${selectedTemplateId === template.id ? ' is-selected' : ''}`}
onClick={() => setSelectedTemplateId(template.id)}
>
<span className="journal-template-card-name">{template.name}</span>
<span className="journal-template-card-desc">{template.description}</span>
</button>
))}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={() => {
setShowCreateModal(false);
setSelectedTemplateId(null);
}}
>
Cancel
</button>
<button
type="submit"
className="admin-btn admin-btn-primary"
disabled={creating || !newTitle.trim()}
>
{creating ? 'Creating...' : 'Create & Open Studio'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import type { ParsedJournal } from '@/lib/journal';
import type { AssetPickTarget } from './BlockFields';
type Frontmatter = ParsedJournal['frontmatter'];
interface StorySettingsModalProps {
frontmatter: Frontmatter;
onChange: (updates: Partial<Frontmatter>) => void;
onPickAsset: (target: AssetPickTarget) => void;
onClose: () => void;
}
/** The entry's metadata — subtitle, author, date, cover, password, draft (#555). */
export function StorySettingsModal({
frontmatter,
onChange,
onPickAsset,
onClose,
}: StorySettingsModalProps) {
return (
<div className="journal-modal-overlay">
<div className="journal-modal-card">
<h3 style={{ margin: '0 0 1.25rem' }}>Story Settings &amp; Metadata</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label
style={{
display: 'block',
fontSize: '0.8rem',
opacity: 0.8,
marginBottom: '4px',
}}
>
Subtitle
</label>
<input
type="text"
className="admin-input"
value={frontmatter.subtitle || ''}
placeholder="e.g. Field notes from our winter journey"
onChange={(e) => onChange({ subtitle: e.target.value })}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div>
<label
style={{
display: 'block',
fontSize: '0.8rem',
opacity: 0.8,
marginBottom: '4px',
}}
>
Author
</label>
<input
type="text"
className="admin-input"
value={frontmatter.author || ''}
placeholder="e.g. Ralf"
onChange={(e) => onChange({ author: e.target.value })}
/>
</div>
<div>
<label
style={{
display: 'block',
fontSize: '0.8rem',
opacity: 0.8,
marginBottom: '4px',
}}
>
Publish Date
</label>
<input
type="date"
className="admin-input"
value={frontmatter.date || ''}
onChange={(e) => onChange({ date: e.target.value })}
/>
</div>
</div>
<div>
<label
style={{
display: 'block',
fontSize: '0.8rem',
opacity: 0.8,
marginBottom: '4px',
}}
>
Cover Photo
</label>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<div
style={{
width: '70px',
height: '50px',
borderRadius: '6px',
background: 'rgba(0,0,0,0.3)',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{frontmatter.coverAssetId ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/admin/thumbnail/${frontmatter.coverAssetId}`}
alt="Cover"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span style={{ fontSize: '0.75rem', opacity: 0.5 }}>None</span>
)}
</div>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
onPickAsset({
title: 'Select Cover Photo',
onSelect: (id) => onChange({ coverAssetId: id }),
})
}
>
Choose Cover
</button>
{frontmatter.coverAssetId && (
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-danger"
onClick={() => onChange({ coverAssetId: undefined })}
>
Remove
</button>
)}
</div>
</div>
<div>
<label
style={{
display: 'block',
fontSize: '0.8rem',
opacity: 0.8,
marginBottom: '4px',
}}
>
Password Protection (Optional)
</label>
<input
type="password"
className="admin-input"
value={frontmatter.password || ''}
placeholder="Leave empty for public access"
onChange={(e) => onChange({ password: e.target.value })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', paddingTop: '4px' }}>
<input
type="checkbox"
id="draft-checkbox"
checked={!!frontmatter.draft}
onChange={(e) => onChange({ draft: e.target.checked })}
/>
<label htmlFor="draft-checkbox" style={{ fontSize: '0.9rem', cursor: 'pointer' }}>
Keep as Draft (Hidden from public /journal list)
</label>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1.5rem' }}>
<button type="button" className="admin-btn admin-btn-primary" onClick={() => onClose()}>
Done
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import type { JournalBlock } from '@/lib/journal';
/**
* Block-list operations of the journal editor, as plain functions so they can
* be tested without rendering the editor (#555).
*/
/**
* Photo blocks may carry a legacy positional reference ("1", "2") instead of an
* asset UUID. Those only ever resolved against a subpage's album; on a
* standalone journal page there is no album, so the photo silently disappears.
* Flag them in the editor so the author can re-pick before publishing.
*/
const ASSET_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isLegacyAssetRef(assetId: string): boolean {
return assetId.length > 0 && !ASSET_UUID.test(assetId);
}
/** The block the "Add Block" toolbar inserts for each type. */
export function createBlock(type: JournalBlock['type']): JournalBlock {
switch (type) {
case 'heading':
return { type: 'heading', level: 2, text: 'New Heading' };
case 'paragraph':
return { type: 'paragraph', html: 'Enter paragraph text here...' };
case 'quote':
return { type: 'quote', text: 'Enter quote text...', author: '' };
case 'photo':
return { type: 'photo', assetId: '', caption: '', layout: 'contained' };
case 'photo-pair':
return { type: 'photo-pair', assetIds: ['', ''], caption: '' };
case 'photo-grid':
return { type: 'photo-grid', assetIds: ['', '', ''], caption: '' };
case 'facts':
return {
type: 'facts',
items: [
{ label: '', value: '' },
{ label: '', value: '' },
],
};
case 'map':
return { type: 'map', caption: '', line: true, items: [] };
case 'album':
return { type: 'album', albumId: '', layout: 'grid' };
}
}
/**
* The blocks with the one at `index` moved a step up or down, or null when it
* is already at that end — the arrow buttons are disabled there, but a stale
* click must not reorder anything.
*/
export function moveBlock(
blocks: JournalBlock[],
index: number,
direction: 'up' | 'down',
): JournalBlock[] | null {
const target = direction === 'up' ? index - 1 : index + 1;
if (target < 0 || target >= blocks.length) return null;
const next = [...blocks];
const [moved] = next.splice(index, 1);
next.splice(target, 0, moved);
return next;
}

View File

@@ -395,6 +395,188 @@
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
} }
/* ── Template picker (Create modal) ──────────────────────────────── */
.journal-template-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
max-height: 260px;
overflow-y: auto;
padding: 2px;
}
.journal-template-card {
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
text-align: left;
padding: 0.55rem 0.7rem;
background: var(--admin-bg-input, #161616);
/* <button> does not inherit color or font from its parent — without these
the cards render in the UA default, black on the dark admin surface. */
color: var(--admin-text, #f0f0f0);
font: inherit;
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.15s ease;
}
.journal-template-card:hover {
border-color: var(--admin-border-hover, #444);
}
.journal-template-card.is-selected {
border-color: var(--admin-accent, #6366f1);
}
.journal-template-card-name {
font-size: 0.85rem;
font-weight: 600;
}
.journal-template-card-desc {
font-size: 0.75rem;
opacity: 0.65;
}
/* ── Photo grid block (editor tiles) ─────────────────────────────── */
.journal-grid-tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 8px;
}
.journal-grid-tile {
position: relative;
}
.journal-grid-tile-pick {
height: 80px;
background: rgba(0, 0, 0, 0.3);
border: 1px dashed rgba(255, 255, 255, 0.15);
border-radius: 6px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.8rem;
opacity: 0.85;
}
.journal-grid-tile-pick img {
width: 100%;
height: 100%;
object-fit: cover;
}
.journal-grid-tile-remove {
position: absolute;
top: 4px;
right: 4px;
padding: 2px 5px;
}
/* ── Facts block (editor rows) ───────────────────────────────────── */
.journal-facts-row {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: 8px;
align-items: center;
}
/* ── Album block (editor) ────────────────────────────────────────── */
.journal-album-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.journal-album-field {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.7rem;
opacity: 0.85;
flex: 1 1 120px;
min-width: 0;
}
.journal-album-field .admin-input {
width: 100%;
}
/* ── Map block (editor pin rows) ─────────────────────────────────── */
.journal-map-row {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.journal-map-row .admin-input {
flex: 1 1 140px;
width: auto;
min-width: 0;
}
.journal-map-row .journal-map-coord {
flex: 0 1 110px;
}
.journal-map-index {
width: 1.4rem;
font-size: 0.7rem;
opacity: 0.6;
text-align: right;
flex: none;
}
.journal-map-photo {
width: 96px;
height: 64px;
flex: none;
background: rgba(0, 0, 0, 0.3);
border: 1px dashed rgba(255, 255, 255, 0.15);
border-radius: 6px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.75rem;
opacity: 0.85;
}
.journal-map-photo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.journal-map-all {
flex: 1;
font-size: 0.8rem;
opacity: 0.8;
}
.journal-map-line-toggle {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
cursor: pointer;
}
/* ── Card meta & status ───────────────────────────────────────────── */ /* ── Card meta & status ───────────────────────────────────────────── */
.journal-admin-card-meta span { .journal-admin-card-meta span {

View File

@@ -0,0 +1,109 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import type React from 'react';
/**
* The draggable divider between the journal editor's authoring pane and its
* preview (#555). The arithmetic is plain functions; `useSplitPane` owns the
* state, the drag listeners and persistence.
*/
/** Width of the authoring pane, in percent of the split view. */
export const SPLIT_STORAGE_KEY = 'folio-journal-split';
export const SPLIT_MIN = 25;
export const SPLIT_MAX = 70;
export const SPLIT_DEFAULT = 46;
export function clampSplit(pct: number): number {
return Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, pct));
}
/** The width for a pointer at `clientX` over a split view at `left`/`width`. */
export function splitFromPointer(clientX: number, left: number, width: number): number | null {
if (width === 0) return null;
return clampSplit(((clientX - left) / width) * 100);
}
/**
* Keyboard access for the divider: arrows nudge (Shift for bigger steps), Home
* resets. Null for any other key, which the divider then leaves alone.
*/
export function splitAfterKey(pct: number, key: string, shiftKey: boolean): number | null {
const step = shiftKey ? 10 : 2;
if (key === 'ArrowLeft') return Math.max(SPLIT_MIN, pct - step);
if (key === 'ArrowRight') return Math.min(SPLIT_MAX, pct + step);
if (key === 'Home') return SPLIT_DEFAULT;
return null;
}
/** A stored width, or null when it is missing or out of range. */
export function parseStoredSplit(raw: string | null): number | null {
const stored = Number(raw);
return stored >= SPLIT_MIN && stored <= SPLIT_MAX ? stored : null;
}
export function useSplitPane() {
const splitRef = useRef<HTMLDivElement>(null);
const [splitPct, setSplitPct] = useState(SPLIT_DEFAULT);
const [dragging, setDragging] = useState(false);
// Restore the last width after mount (localStorage is unavailable on the server,
// and reading it in the initial state would not match the server render).
useEffect(() => {
const stored = parseStoredSplit(window.localStorage.getItem(SPLIT_STORAGE_KEY));
// eslint-disable-next-line react-hooks/set-state-in-effect
if (stored !== null) setSplitPct(stored);
}, []);
const applySplit = useCallback((clientX: number) => {
const rect = splitRef.current?.getBoundingClientRect();
if (!rect) return;
const pct = splitFromPointer(clientX, rect.left, rect.width);
if (pct !== null) setSplitPct(pct);
}, []);
useEffect(() => {
if (!dragging) return;
const onMove = (e: MouseEvent) => {
e.preventDefault();
applySplit(e.clientX);
};
const onUp = () => setDragging(false);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [dragging, applySplit]);
// Persist once the drag ends, not on every pixel.
useEffect(() => {
if (dragging) return;
window.localStorage.setItem(SPLIT_STORAGE_KEY, String(Math.round(splitPct)));
}, [dragging, splitPct]);
const onResizerMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setDragging(true);
};
const onResizerDoubleClick = () => setSplitPct(SPLIT_DEFAULT);
const onResizerKeyDown = (e: React.KeyboardEvent) => {
const next = splitAfterKey(splitPct, e.key, e.shiftKey);
if (next === null) return;
e.preventDefault();
setSplitPct(next);
};
return {
splitRef,
splitPct,
dragging,
onResizerMouseDown,
onResizerDoubleClick,
onResizerKeyDown,
};
}

View File

@@ -100,6 +100,43 @@ export default function AlbumDrawer({
/> />
</div> </div>
</div> </div>
<div className="admin-field">
<label>Downloads</label>
<button
type="button"
className={`admin-toggle-card ${album.download ? 'active' : ''}`}
onClick={() => onUpdate({ download: !album.download })}
aria-pressed={!!album.download}
style={{
padding: '10px 14px',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderRadius: '8px',
cursor: 'pointer',
}}
>
<div className="toggle-card-info" style={{ textAlign: 'left' }}>
<span
className="toggle-card-title"
style={{ fontWeight: 600, display: 'inline-flex', alignItems: 'center' }}
>
Allow original downloads
</span>
<span
className="toggle-card-desc"
style={{ fontSize: '0.8rem', display: 'block', opacity: 0.75 }}
>
Visitors can download the whole album or their selected photos as a ZIP
</span>
</div>
<div className={`switch-toggle ${album.download ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
</div>
</div> </div>
<div className="admin-sheet-col admin-sheet-col--divided"> <div className="admin-sheet-col admin-sheet-col--divided">

View File

@@ -0,0 +1,135 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Unsaved editor state that survives leaving the editor (#592).
*
* `useUnsavedGuard` only covers leaving the browser. Inside the admin, a tab
* link, the back button, Reload or Logout unmounts the page builder or the
* journal editor and its state goes with it. Rather than guard every exit —
* App Router has no `router.events`, and `<Link onNavigate>` does not see the
* back button — the editor keeps a draft in `sessionStorage` and picks it up
* again when it next mounts.
*
* A draft carries the `base` it was edited from: a fingerprint of what the
* server returned. When the server still returns the same thing the draft is
* applied on load. When it does not — the file was saved from another tab, or a
* backup was restored — applying it would silently overwrite that change, so it
* is held back and the editor asks.
*
* `sessionStorage` rather than `localStorage`: a draft belongs to the tab it was
* typed in, and should not follow the operator into a second tab or outlive the
* browser session.
*/
const PREFIX = 'folio_draft_';
export interface Draft<T> {
base: string;
value: T;
}
/**
* Storage can throw — a private window, a full quota, blocked site data. A
* draft is a convenience, so every failure degrades to "no draft".
*/
export function readDraft<T>(key: string): Draft<T> | null {
try {
const raw = window.sessionStorage.getItem(PREFIX + key);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Draft<T>>;
if (typeof parsed?.base !== 'string' || !('value' in parsed)) return null;
return { base: parsed.base, value: parsed.value as T };
} catch {
return null;
}
}
export function writeDraft<T>(key: string, draft: Draft<T>): void {
try {
window.sessionStorage.setItem(PREFIX + key, JSON.stringify(draft));
} catch {
// Quota or blocked storage: the edit is still in memory, just not kept.
}
}
export function clearDraft(key: string): void {
try {
window.sessionStorage.removeItem(PREFIX + key);
} catch {
// Nothing to clean up if storage is unavailable.
}
}
/**
* What the editor should show after loading:
* - `restored` — a draft was applied; offer to discard it.
* - `conflict` — a draft exists but the server changed since; offer to apply
* it anyway or discard it.
*/
export type DraftStatus = 'none' | 'restored' | 'conflict';
export function useDraft<T>(key: string, value: T, dirty: boolean) {
/** The server fingerprint the current edits started from; null until loaded. */
const baseRef = useRef<string | null>(null);
const [status, setStatus] = useState<DraftStatus>('none');
// 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
// base, so the empty state an editor mounts with never lands in storage.
useEffect(() => {
if (baseRef.current === null) return;
if (dirty) writeDraft(key, { base: baseRef.current, value });
else clearDraft(key);
}, [key, value, dirty]);
/**
* Call once the server state has loaded, with its fingerprint. Returns the
* draft to apply, or null to keep what the server sent.
*/
const load = useCallback(
(base: string): T | null => {
baseRef.current = base;
const draft = readDraft<T>(key);
if (!draft) {
setStatus('none');
return null;
}
if (draft.base === base) {
setStatus('restored');
return draft.value;
}
setStatus('conflict');
return null;
},
[key],
);
/** Call after a successful save, with the fingerprint of what was saved. */
const saved = useCallback(
(base: string) => {
baseRef.current = base;
clearDraft(key);
setStatus('none');
},
[key],
);
/** The held-back draft of a `conflict`, for "restore anyway". */
const takeConflicting = useCallback((): T | null => {
const draft = readDraft<T>(key);
setStatus(draft ? 'restored' : 'none');
return draft?.value ?? null;
}, [key]);
/** Forget the draft. The editor reloads the server state itself. */
const discard = useCallback(() => {
clearDraft(key);
setStatus('none');
}, [key]);
const dismiss = useCallback(() => setStatus('none'), []);
return { status, load, saved, takeConflicting, discard, dismiss };
}

View File

@@ -1,4 +1,5 @@
import { JournalStudio } from '../../components/JournalStudio'; import { JournalStudio } from '../../components/journal/JournalStudio';
import { getConfig } from '@/lib/config';
export default async function AdminJournalEntryPage({ export default async function AdminJournalEntryPage({
params, params,
@@ -6,5 +7,5 @@ export default async function AdminJournalEntryPage({
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}) { }) {
const { slug } = await params; const { slug } = await params;
return <JournalStudio slug={slug} />; return <JournalStudio slug={slug} mapEnabled={getConfig().map} />;
} }

View File

@@ -1,5 +1,6 @@
import { JournalStudio } from '../components/JournalStudio'; import { JournalStudio } from '../components/journal/JournalStudio';
import { getConfig } from '@/lib/config';
export default function AdminJournalPage() { export default function AdminJournalPage() {
return <JournalStudio />; return <JournalStudio mapEnabled={getConfig().map} />;
} }

View File

@@ -310,3 +310,18 @@
font-size: 0.8rem; font-size: 0.8rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
/* Restored or held-back editor drafts (#592): text, then its two actions. */
.draft-notice {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 1rem;
}
.draft-notice-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}

View File

@@ -87,6 +87,45 @@
} }
/* The card's own hover state is enough of a highlight; the chip stays quiet. */ /* The card's own hover state is enough of a highlight; the chip stays quiet. */
/* Drag handle and the card while it is carried (SortableBlocks.tsx). */
.essay-block-card-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.essay-block-grip {
display: inline-flex;
align-items: center;
padding: 2px;
background: none;
border: none;
border-radius: 4px;
color: var(--admin-text-muted);
cursor: grab;
touch-action: none;
}
.essay-block-grip:hover,
.essay-block-grip:focus-visible {
color: var(--admin-text);
background: var(--admin-bg-elevated, rgba(255, 255, 255, 0.06));
outline: none;
}
.essay-block-grip:active,
.essay-block-card.is-dragging .essay-block-grip {
cursor: grabbing;
}
.essay-block-card.is-dragging {
position: relative;
z-index: 2;
opacity: 0.85;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}
.essay-block-card:hover .essay-block-badge { .essay-block-card:hover .essay-block-badge {
border-color: var(--admin-border-hover); border-color: var(--admin-border-hover);
color: var(--admin-text); color: var(--admin-text);

View File

@@ -81,6 +81,14 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
name: 'GET /api/og', name: 'GET /api/og',
call: async () => (await import('../og/route')).GET(request('/api/og?title=x') as never), call: async () => (await import('../og/route')).GET(request('/api/og?title=x') as never),
}, },
{
name: 'GET /api/download/[album]/archive',
call: async () =>
(await import('../download/[album]/archive/route')).GET(
request('/api/download/tok/archive') as never,
{ params: Promise.resolve({ album: 'tok' }) } as never,
),
},
]; ];
describe('public content routes behind a locked site', () => { describe('public content routes behind a locked site', () => {

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from 'vitest';
import { NextRequest } from 'next/server';
/**
* The admin session cookie is `Secure` only when the visitor came over HTTPS
* (#664). Keyed on NODE_ENV it was always `Secure` in the Docker image, so an
* instance opened at `http://host:7211` logged in with a 200 and then lost the
* cookie — every admin request after that was a 401.
*/
vi.mock('@/lib/admin/auth', () => ({
verifyAdminPassword: vi.fn(async () => true),
createAdminToken: vi.fn(() => 'session-token'),
isAdminAuthenticated: vi.fn(async () => true),
isAdminEnabled: vi.fn(() => true),
COOKIE_NAME: 'folio_admin_session',
SESSION_DURATION_MS: 24 * 60 * 60 * 1000,
MAX_PASSWORD_LENGTH: 1024,
}));
vi.mock('@/lib/rate-limit', () => ({
checkRateLimit: vi.fn(() => ({ success: true, resetAt: 0 })),
getClientIp: vi.fn(() => '127.0.0.1'),
retryAfterSeconds: vi.fn(() => 60),
}));
import { POST, DELETE } from '../auth/route';
function login(url: string, headers: Record<string, string> = {}) {
return new NextRequest(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ password: 'hunter2' }),
});
}
describe('admin session cookie', () => {
it('is not Secure when the admin is reached over plain HTTP', async () => {
const res = await POST(login('http://192.168.1.10:7211/api/admin/auth'));
expect(res.status).toBe(200);
const cookie = res.headers.get('set-cookie') ?? '';
expect(cookie).toContain('folio_admin_session=session-token');
expect(cookie.toLowerCase()).not.toContain('secure');
});
it('is Secure behind an HTTPS reverse proxy', async () => {
const res = await POST(
login('http://folio:3000/api/admin/auth', { 'x-forwarded-proto': 'https' }),
);
expect((res.headers.get('set-cookie') ?? '').toLowerCase()).toContain('secure');
});
it('clears the cookie with the same flag it was set with', async () => {
const res = await DELETE(
new NextRequest('http://192.168.1.10:7211/api/admin/auth', { method: 'DELETE' }),
);
const cookie = res.headers.get('set-cookie') ?? '';
expect(cookie).toContain('folio_admin_session=');
expect(cookie.toLowerCase()).not.toContain('secure');
});
});

View File

@@ -9,6 +9,7 @@ import {
MAX_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH,
} from '@/lib/admin/auth'; } from '@/lib/admin/auth';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit'; import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { isHttpsRequest } from '@/lib/auth';
/** Admin login attempts per minute per IP — the highest-value credential in the app. */ /** Admin login attempts per minute per IP — the highest-value credential in the app. */
const ADMIN_AUTH_RPM = 5; const ADMIN_AUTH_RPM = 5;
@@ -53,7 +54,7 @@ export async function POST(request: NextRequest) {
response.cookies.set(COOKIE_NAME, token, { response.cookies.set(COOKIE_NAME, token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: isHttpsRequest(request),
sameSite: 'strict', sameSite: 'strict',
path: '/', path: '/',
maxAge: SESSION_DURATION_MS / 1000, maxAge: SESSION_DURATION_MS / 1000,
@@ -73,11 +74,11 @@ export async function GET() {
} }
/** DELETE: Logout. */ /** DELETE: Logout. */
export async function DELETE() { export async function DELETE(request: NextRequest) {
const response = NextResponse.json({ success: true }); const response = NextResponse.json({ success: true });
response.cookies.set(COOKIE_NAME, '', { response.cookies.set(COOKIE_NAME, '', {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: isHttpsRequest(request),
sameSite: 'strict', sameSite: 'strict',
path: '/', path: '/',
maxAge: 0, maxAge: 0,

View File

@@ -107,7 +107,10 @@ export const GET = withAdmin(async (request: NextRequest) => {
// it), which is only known once Immich has answered (#632). // it), which is only known once Immich has answered (#632).
const slugGroups: AlbumSlugGroup[] = [ const slugGroups: AlbumSlugGroup[] = [
{ context: 'gallery.yaml albums', albumIds: config.standaloneAlbums }, { context: 'gallery.yaml albums', albumIds: config.standaloneAlbums },
...config.subpages.map((sp) => ({ context: `subpage "${sp.name}"`, albumIds: sp.albumIds })), ...config.subpages.map((sp) => ({
context: `subpage "${sp.name}"`,
albumIds: sp.albumIds,
})),
]; ];
findings.push(checkAlbumSlugCollisions(slugGroups, config.albumOverrides, albums, slugify)); findings.push(checkAlbumSlugCollisions(slugGroups, config.albumOverrides, albums, slugify));
} }

View File

@@ -82,7 +82,10 @@ export async function POST(req: NextRequest) {
{ error: 'Too many requests' }, { error: 'Too many requests' },
{ {
status: 429, status: 429,
headers: { 'Retry-After': String(retryAfterSeconds(resetAt)), 'Cache-Control': 'no-store' }, headers: {
'Retry-After': String(retryAfterSeconds(resetAt)),
'Cache-Control': 'no-store',
},
}, },
); );
} }
@@ -111,7 +114,10 @@ export async function POST(req: NextRequest) {
// An already-tracked path may still increment past the cap; only a // An already-tracked path may still increment past the cap; only a
// brand-new key is refused once the day is full. // brand-new key is refused once the day is full.
if (dayObj.pages[pagePath] !== undefined || Object.keys(dayObj.pages).length < MAX_PAGES_PER_DAY) { if (
dayObj.pages[pagePath] !== undefined ||
Object.keys(dayObj.pages).length < MAX_PAGES_PER_DAY
) {
dayObj.pages[pagePath] = (dayObj.pages[pagePath] || 0) + 1; dayObj.pages[pagePath] = (dayObj.pages[pagePath] || 0) + 1;
} }

View File

@@ -7,7 +7,7 @@
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { authenticate, isProtected, type ProtectedType } from '@/lib/auth'; import { authenticate, isHttpsRequest, isProtected, type ProtectedType } from '@/lib/auth';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit'; import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
/** Tight limit for auth attempts — 10 per minute per IP. */ /** Tight limit for auth attempts — 10 per minute per IP. */
@@ -71,7 +71,7 @@ export async function POST(request: NextRequest) {
); );
} }
const setCookie = await authenticate(slug, password, type); const setCookie = await authenticate(slug, password, type, isHttpsRequest(request));
if (!setCookie) { if (!setCookie) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 }); return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
} }

View File

@@ -0,0 +1,313 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
/**
* The archive route streams originals as a ZIP for either the whole album
* (GET) or a proofing selection (POST). It shares the single-asset download's
* checks — allowlist, `download: true`, password gates, asset membership — and
* adds one of its own: a selected token must decode to an asset that actually
* belongs to the album it names.
*/
vi.mock('@/lib/config', () => ({ getConfig: vi.fn(), getConfigOrNull: vi.fn() }));
vi.mock('@/lib/tokens', () => ({ decodeAssetId: vi.fn() }));
vi.mock('@/lib/immich', () => ({
immich: { getAlbum: vi.fn(), streamAsset: vi.fn() },
ImmichUnavailableError: class ImmichUnavailableError extends Error {},
}));
vi.mock('@/lib/rate-limit', () => ({
checkRateLimit: vi.fn(() => ({ success: true, resetAt: 0 })),
getClientIp: vi.fn(() => '127.0.0.1'),
retryAfterSeconds: vi.fn(() => 60),
}));
vi.mock('@/lib/auth', () => ({
siteLockResponse: vi.fn(() => null),
isAlbumReachable: vi.fn(() => true),
}));
vi.mock('next/headers', () => ({
cookies: vi.fn(() => Promise.resolve({ get: () => undefined })),
}));
import { GET, POST } from '../route';
import { getConfig, getConfigOrNull } from '@/lib/config';
import { decodeAssetId } from '@/lib/tokens';
import { immich } from '@/lib/immich';
const mockConfig = getConfig as unknown as ReturnType<typeof vi.fn>;
const mockConfigOrNull = getConfigOrNull as unknown as ReturnType<typeof vi.fn>;
const mockDecode = decodeAssetId as unknown as ReturnType<typeof vi.fn>;
const mockGetAlbum = immich.getAlbum as unknown as ReturnType<typeof vi.fn>;
const mockStream = immich.streamAsset as unknown as ReturnType<typeof vi.fn>;
const ALBUM = {
id: 'album-uuid',
albumName: 'Test Album',
assets: [
{ id: 'asset-1', type: 'IMAGE', originalFileName: 'photo-1.jpg' },
{ id: 'asset-2', type: 'IMAGE', originalFileName: 'photo-2.jpg' },
],
};
/** A config where the album is on the allowlist and opted into downloads. */
function optedIn() {
return { albums: ['album-uuid'], albumDownloads: { 'album-uuid': true } };
}
/** Decode album/asset tokens into the UUIDs under test; unknown → null. */
function decodeMap() {
mockDecode.mockImplementation((token: string) => {
if (token === 'album-token') return 'album-uuid';
if (token === 'asset-token-1') return 'asset-1';
if (token === 'asset-token-2') return 'asset-2';
if (token === 'asset-token-foreign') return 'asset-foreign';
return null;
});
}
/** A tiny originals stream, standing in for Immich's response body. */
function originStream(bytes: string) {
const data = new TextEncoder().encode(bytes);
return {
stream: new ReadableStream({
start(controller) {
controller.enqueue(data);
controller.close();
},
}),
contentType: 'image/jpeg',
contentLength: null,
};
}
const getReq = () => new NextRequest('http://localhost/api/download/album-token/archive');
const params = { params: Promise.resolve({ album: 'album-token' }) };
function postReq(tokens: unknown[]) {
return new NextRequest('http://localhost/api/download/album-token/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assets: tokens }),
});
}
/** The form POST the proofing modal makes — one `assets` field per token. */
function formReq(tokens: string[]) {
const body = tokens.map((t) => `assets=${encodeURIComponent(t)}`).join('&');
return new NextRequest('http://localhost/api/download/album-token/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
}
const browsesHtml = (referer?: string) =>
new NextRequest('http://localhost/api/download/album-token/archive', {
headers: {
Accept: 'text/html,application/xhtml+xml',
...(referer ? { Referer: referer } : {}),
},
});
beforeEach(() => {
vi.clearAllMocks();
decodeMap();
mockConfig.mockReturnValue(optedIn());
mockConfigOrNull.mockReturnValue(null);
mockGetAlbum.mockResolvedValue(ALBUM);
// A fresh stream per call — a real Immich fetch never hands back the same
// body twice, and a shared web stream would be locked by the first consumer.
mockStream.mockImplementation(() => Promise.resolve(originStream('image-bytes')));
});
describe('GET /api/download/[album]/archive', () => {
it('streams a ZIP of the whole album when opted in', async () => {
const res = await GET(getReq(), params);
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/zip');
expect(res.headers.get('content-disposition')).toContain('attachment');
const bytes = new Uint8Array(await res.arrayBuffer());
expect(bytes[0]).toBe(0x50); // 'P'
expect(bytes[1]).toBe(0x4b); // 'K'
});
it('refuses an album that has not opted into downloads', async () => {
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
const res = await GET(getReq(), params);
expect(res.status).toBe(404);
});
it('refuses an album that is not on the allowlist', async () => {
mockConfig.mockReturnValue({ albums: [], albumDownloads: { 'album-uuid': true } });
const res = await GET(getReq(), params);
expect(res.status).toBe(404);
});
it('refuses a token that does not decode', async () => {
mockDecode.mockImplementation(() => null);
const res = await GET(getReq(), params);
expect(res.status).toBe(404);
});
});
describe('POST /api/download/[album]/archive', () => {
it('streams only the selected assets', async () => {
mockStream
.mockResolvedValueOnce(originStream('one'))
.mockResolvedValueOnce(originStream('two'));
const res = await POST(postReq(['asset-token-1', 'asset-token-2']), params);
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/zip');
const bytes = new Uint8Array(await res.arrayBuffer());
expect(bytes[0]).toBe(0x50);
expect(bytes[1]).toBe(0x4b);
expect(mockStream).toHaveBeenCalledTimes(2);
});
it('refuses a token that decodes to an asset outside the album', async () => {
const res = await POST(postReq(['asset-token-foreign']), params);
expect(res.status).toBe(404);
});
it('refuses an empty selection', async () => {
const res = await POST(postReq([]), params);
expect(res.status).toBe(404);
});
it('refuses a malformed body', async () => {
const req = new NextRequest('http://localhost/api/download/album-token/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 'not json',
});
const res = await POST(req, params);
expect(res.status).toBe(404);
});
it('accepts the form body the proofing modal posts', async () => {
const res = await POST(formReq(['asset-token-1', 'asset-token-2']), params);
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/zip');
// Draining the body drives the background streaming loop to completion.
await res.arrayBuffer();
expect(mockStream).toHaveBeenCalledTimes(2);
});
/**
* `byId.get()` is a lookup, not a consume, so without a `seen` set the same
* token could be appended once per occurrence — a 2 GB video repeated 800
* times is 1.6 TB off the Immich server.
*/
it('appends a repeated token only once', async () => {
const res = await POST(
postReq(['asset-token-1', 'asset-token-1', 'asset-token-1', 'asset-token-1']),
params,
);
expect(res.status).toBe(200);
await res.arrayBuffer();
expect(mockStream).toHaveBeenCalledTimes(1);
});
it('refuses a selection past the absolute ceiling', async () => {
const assets = Array.from({ length: 1001 }, (_, i) => ({
id: `asset-${i}`,
type: 'IMAGE',
originalFileName: `photo-${i}.jpg`,
}));
mockGetAlbum.mockResolvedValue({ ...ALBUM, assets });
mockDecode.mockImplementation((token: string) =>
token === 'album-token' ? 'album-uuid' : token.replace('token-', ''),
);
const tokens = assets.map((a) => `token-${a.id}`);
const res = await POST(postReq(tokens), params);
expect(res.status).toBe(404);
});
it('refuses a body past the size cap', async () => {
const big = JSON.stringify({ assets: Array(30000).fill('asset-token-1') });
const req = new NextRequest('http://localhost/api/download/album-token/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: big,
});
const res = await POST(req, params);
expect(res.status).toBe(404);
});
});
describe('refusals', () => {
it('renders HTML for a browser navigation instead of raw JSON', async () => {
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
const res = await GET(browsesHtml(), params);
expect(res.status).toBe(404);
expect(res.headers.get('content-type')).toContain('text/html');
});
it("speaks the site's language", async () => {
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
mockConfigOrNull.mockReturnValue({ lang: 'de' });
const html = await (await GET(browsesHtml(), params)).text();
expect(html).toContain('<html lang="de">');
expect(html).toContain('Download nicht möglich');
expect(html).toContain('Zurück zur Galerie');
});
it('links back to the page the download started from', async () => {
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
const html = await (
await GET(browsesHtml('http://localhost/deutschland/kloster-chorin?fav=abc'), params)
).text();
expect(html).toContain('href="/deutschland/kloster-chorin?fav=abc"');
});
it('does not follow a foreign referer', async () => {
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
const html = await (await GET(browsesHtml('https://evil.example/phish'), params)).text();
expect(html).toContain('href="/"');
expect(html).not.toContain('evil.example');
});
it('keeps JSON for an API caller', async () => {
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
const res = await GET(getReq(), params);
expect(res.status).toBe(404);
expect(res.headers.get('content-type')).toContain('application/json');
});
});
/**
* An original that is still arriving when the visitor gives up must be let go:
* the loop stopping is not enough, because the upstream body keeps its socket
* out of undici's pool until something cancels it (#635).
*/
describe('abort', () => {
/** An original that sends one chunk and then never finishes. */
function stallingStream(onCancel: () => void) {
return {
stream: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('first-chunk'));
},
cancel: onCancel,
}),
contentType: 'image/jpeg',
contentLength: null,
};
}
it('cancels the original in flight when the download is aborted', async () => {
const cancelled = vi.fn();
mockStream.mockImplementation(() => Promise.resolve(stallingStream(cancelled)));
const res = await GET(getReq(), params);
const reader = res.body!.getReader();
await reader.read(); // the entry is under way
await reader.cancel();
await vi.waitFor(() => expect(cancelled).toHaveBeenCalled());
// The loop stopped rather than moving on to the next original.
expect(mockStream).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,422 @@
/**
* Archive download route — streams an album, or a selection of its assets, as
* a ZIP of the originals.
*
* Sits beside the single-asset download and re-checks the exact same things:
*
* - the album must be on the allowlist,
* - the album must have opted in with `download: true`,
* - every password gate on a route to it must be satisfied, and
* - every asset requested must actually belong to that album.
*
* A `GET` returns the whole album. A `POST` returns a selection, accepting
* either `application/json` (`{ "assets": [<token>, …] }`) or the `assets=…`
* form body the proofing modal posts — the form matters because the browser
* then streams the response straight to disk instead of buffering the whole ZIP
* in a blob.
*/
import { NextRequest, NextResponse } from 'next/server';
import { Readable } from 'node:stream';
import archiver from 'archiver';
import { cookies } from 'next/headers';
import { immich, ImmichUnavailableError, type ImmichAsset } from '@/lib/immich';
import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { isAlbumReachable, siteLockResponse } from '@/lib/auth';
import { contentDisposition, safeDownloadName } from '@/lib/downloadName';
import { getDictionary } from '@/lib/i18n';
import { getLocale, getServerDictionary } from '@/lib/i18n/server';
export const dynamic = 'force-dynamic';
/**
* Far below even the single-asset limit: one request may pull down an entire
* album of originals, so it must be a deliberate, rare action per visitor.
*/
const ARCHIVE_RPM = 5;
/**
* Absolute ceiling on one selection, independent of album size. Together with
* the token de-duplication in POST it bounds the upstream egress a single
* request can ask for: without it, one 2 GB video's token repeated a thousand
* times would stream 2 TB out of Immich.
*/
const MAX_SELECTION_ASSETS = 1000;
/**
* Cap on the POST body, enforced while reading it rather than after. The token
* list is the only payload a POST carries, so 256 KB comfortably fits the
* ceiling above and a short body can never become a large parse.
*/
const MAX_BODY_BYTES = 256 * 1024;
/** Escape a value interpolated into the refusal page. */
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) =>
char === '&'
? '&amp;'
: char === '<'
? '&lt;'
: char === '>'
? '&gt;'
: char === '"'
? '&quot;'
: '&#39;',
);
}
/**
* A refusal, rendered for whoever asked.
*
* A download begins as a navigation — the header link, or the proofing modal's
* form POST — and only a success carries `Content-Disposition: attachment`. A
* bare JSON body would therefore replace the gallery with
* `{"error":"Too many requests"}` on a rate limit, which is the likely failure
* (5 rpm, and the ZIP shows nothing until the first byte, so people click
* again). Browsers get a page with a way back; callers asking for JSON keep it.
*
* The page speaks the site's language; the JSON stays English, like every other
* API error.
*/
function refusal(
request: NextRequest,
status: number,
reason: RefusalReason,
retryAfter?: number,
): NextResponse {
const headers: Record<string, string> = { 'Cache-Control': 'no-store' };
if (retryAfter) headers['Retry-After'] = String(retryAfter);
if (!(request.headers.get('accept') ?? '').includes('text/html')) {
return NextResponse.json({ error: getDictionary('en').download[reason] }, { status, headers });
}
const t = getServerDictionary().download;
headers['Content-Type'] = 'text/html; charset=utf-8';
return new NextResponse(
`<!doctype html><html lang="${getLocale()}"><head><meta charset="utf-8">` +
'<meta name="viewport" content="width=device-width, initial-scale=1">' +
`<title>${escapeHtml(t.unavailableTitle)}</title></head>` +
'<body style="font-family:system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1.5rem;line-height:1.6">' +
`<h1 style="font-size:1.25rem">${escapeHtml(t.unavailableTitle)}</h1>` +
`<p>${escapeHtml(t[reason])}</p>` +
`<p><a href="${escapeHtml(backHref(request))}">${escapeHtml(t.back)}</a></p>` +
'</body></html>',
{ status, headers },
);
}
type RefusalReason = 'notAvailable' | 'rateLimited' | 'immichUnavailable';
/**
* Where the refusal page's link leads: the page the download was started from,
* so a visitor lands back on the album rather than the home page. Only a
* same-origin `Referer` is followed — anything else would turn this page into an
* open redirect with our name on it.
*/
function backHref(request: NextRequest): string {
const referer = request.headers.get('referer');
if (!referer) return '/';
try {
const url = new URL(referer);
if (url.origin !== request.nextUrl.origin) return '/';
return `${url.pathname}${url.search}`;
} catch {
return '/';
}
}
/**
* A unique, ZIP-safe entry name. Originals can collide ("IMG_0001.jpg" from two
* cards), and a ZIP with duplicate entry names is ambiguous to unzip.
*/
function uniqueEntryName(raw: string | undefined, used: Set<string>): string {
const base = safeDownloadName(raw);
let name = base;
let n = 2;
while (used.has(name)) {
const dot = base.lastIndexOf('.');
name = dot > 0 ? `${base.slice(0, dot)}-${n}${base.slice(dot)}` : `${base}-${n}`;
n++;
}
used.add(name);
return name;
}
/**
* Append one entry, resolving once it has been written (or the archive has been
* torn down because the visitor left).
*
* archiver processes entries strictly one at a time — that is what keeps memory
* flat — but `append()` only *queues* them. Firing every `append` up front (and
* so opening every upstream request at once) is what made a large album's later
* originals sit long enough to time out and truncate the ZIP. Awaiting the
* `entry` event holds the loop until the previous file has finished, so only
* one original is ever in flight. `close` resolves too, so an aborted download
* stops the loop rather than hanging on a file nothing will read.
*
* On `close` and `error` the source is destroyed as well. archiver does not do
* that for a queued or half-read entry, and the upstream body would otherwise
* hold its socket out of undici's pool until GC finalises it (#635).
*/
function appendEntry(archive: archiver.Archiver, source: Readable, name: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const cleanup = () => {
archive.off('entry', onEntry);
archive.off('error', onError);
archive.off('close', onClose);
};
const onEntry = () => {
cleanup();
resolve();
};
// `close` fires when the response is consumed or aborted. Resolving on it
// lets the loop see `archive.destroyed` and stop, rather than hanging on a
// file nothing will read.
const onClose = () => {
cleanup();
source.destroy();
resolve();
};
const onError = (error: Error) => {
cleanup();
source.destroy();
reject(error);
};
archive.once('entry', onEntry);
archive.once('error', onError);
archive.once('close', onClose);
archive.append(source, { name });
});
}
/** A refusal to short-circuit with, or a decoded album ready to stream. */
type ResolvedAlbum = { error: Response } | { albumName: string; assets: ImmichAsset[] };
/**
* The shared authorisation preamble for both verbs: rate limit, site lock, and
* every check the single-asset download performs before streaming a byte.
*/
async function resolveAlbum(
request: NextRequest,
params: { album: string },
): Promise<ResolvedAlbum> {
const ip = getClientIp(request);
// Its own bucket, not the single-asset `download:` one: the two share a
// client but enforce different limits (5 vs 30 rpm), and letting a handful of
// single-file downloads spend the whole-album budget would be wrong.
const rl = checkRateLimit(`archive:${ip}`, ARCHIVE_RPM);
if (!rl.success) {
return {
error: refusal(request, 429, 'rateLimited', retryAfterSeconds(rl.resetAt)),
};
}
const locked = siteLockResponse(request);
if (locked) return { error: locked };
const albumId = decodeAssetId(params.album);
if (!albumId) return { error: refusal(request, 404, 'notAvailable') };
const config = getConfig();
if (!config.albums.includes(albumId)) return { error: refusal(request, 404, 'notAvailable') };
if (!config.albumDownloads[albumId]) return { error: refusal(request, 404, 'notAvailable') };
// Every gate on every route to the album — see the single-asset route.
const cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value;
if (!isAlbumReachable(albumId, getCookie)) {
return { error: refusal(request, 404, 'notAvailable') };
}
let album;
try {
album = await immich.getAlbum(albumId);
} catch (error) {
if (error instanceof ImmichUnavailableError) {
return {
error: refusal(request, 503, 'immichUnavailable', 30),
};
}
throw error;
}
if (!album) return { error: refusal(request, 404, 'notAvailable') };
const assets = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO');
return { albumName: album.albumName, assets };
}
/**
* Stream `assets` as a ZIP of originals.
*
* archiver writes data descriptors, so entry sizes are never known up front and
* memory stays flat no matter how large the album is. The loop pulls one
* original at a time and stops as soon as the response is gone.
*/
function streamArchive(albumName: string, assets: ImmichAsset[]): NextResponse {
const archive = archiver('zip', { store: true });
archive.on('error', (err) => {
// A visitor cancelling the download is not a failure worth a log line.
if (err.name === 'AbortError') return;
console.error(`[Download] Archive stream failed:`, err);
});
const body = Readable.toWeb(archive) as unknown as ReadableStream;
// Fill the archive in the background: the response has to go out first so the
// browser starts reading, and each originals fetch is awaited in turn.
void (async () => {
try {
const used = new Set<string>();
for (const asset of assets) {
// The visitor left (or the archive failed): stop pulling originals.
if (archive.destroyed) break;
const result = await immich.streamAsset(asset.id, 'original');
if (!result) continue;
if (archive.destroyed) {
// Left while the headers were on their way: release the body unread.
await result.stream.cancel();
break;
}
const nodeStream = Readable.fromWeb(
result.stream as unknown as import('node:stream/web').ReadableStream,
);
await appendEntry(archive, nodeStream, uniqueEntryName(asset.originalFileName, used));
}
if (!archive.destroyed) await archive.finalize();
} catch (err) {
archive.destroy(err instanceof Error ? err : new Error(String(err)));
}
})();
const zipName = `${safeDownloadName(albumName, 'album')}.zip`;
return new NextResponse(body, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': contentDisposition(zipName),
// Private: authorised per visitor, so a shared cache must not hand the
// archive to the next one.
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
});
}
/**
* Read the POST body with a hard cap, then pull the token list out of it.
*
* Read by hand rather than through `request.json()`: that helper parses the
* whole body before anything can object, which is the one place an unbounded
* payload could land. `Content-Length` is checked first (cheap), and the read
* itself stops the moment the cap is passed (so a chunked body is capped too).
*/
async function readSelectedTokens(request: NextRequest): Promise<string[] | null> {
const declared = Number(request.headers.get('content-length') ?? '');
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return null;
let text: string;
try {
const raw = await readBodyCapped(request, MAX_BODY_BYTES);
if (raw === null) return null;
text = raw;
} catch {
return null;
}
const contentType = request.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
try {
const parsed = JSON.parse(text) as { assets?: unknown };
if (!Array.isArray(parsed?.assets)) return null;
return parsed.assets.filter((token): token is string => typeof token === 'string');
} catch {
return null;
}
}
// The proofing modal's form POST: one `assets` field per token.
const form = new URLSearchParams(text);
const tokens = form.getAll('assets').filter((token) => token.length > 0);
return tokens.length > 0 ? tokens : null;
}
/** Read a request body as text, aborting once it exceeds `limit` bytes. */
async function readBodyCapped(request: NextRequest, limit: number): Promise<string | null> {
const stream = request.body;
if (!stream) return '';
const reader = stream.getReader();
const decoder = new TextDecoder();
let text = '';
let bytes = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > limit) {
await reader.cancel();
return null;
}
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
return text;
} finally {
reader.releaseLock();
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ album: string }> },
) {
const { album } = await params;
const resolved = await resolveAlbum(request, { album });
if ('error' in resolved) return resolved.error;
return streamArchive(resolved.albumName, resolved.assets);
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ album: string }> },
) {
const { album } = await params;
const resolved = await resolveAlbum(request, { album });
if ('error' in resolved) return resolved.error;
const tokens = await readSelectedTokens(request);
if (!tokens || tokens.length === 0) {
return refusal(request, 404, 'notAvailable');
}
// Every token must decode to an asset that really belongs to the album; a
// token is unforgeable (AES-GCM), but membership is the check that keeps one
// album's archive from naming another album's assets.
//
// The `seen` set matters as much as the membership check: a token is only a
// lookup, not a consume, so the same asset could otherwise be appended once
// per occurrence. With the absolute ceiling that bounds the whole request.
const byId = new Map(resolved.assets.map((a) => [a.id, a]));
const seen = new Set<string>();
const selected: ImmichAsset[] = [];
for (const token of tokens) {
if (selected.length >= MAX_SELECTION_ASSETS) {
return refusal(request, 404, 'notAvailable');
}
const assetId = decodeAssetId(token);
if (!assetId) return refusal(request, 404, 'notAvailable');
if (seen.has(assetId)) continue;
const asset = byId.get(assetId);
if (!asset) return refusal(request, 404, 'notAvailable');
seen.add(assetId);
selected.push(asset);
}
if (selected.length === 0) {
return refusal(request, 404, 'notAvailable');
}
return streamArchive(resolved.albumName, selected);
}

View File

@@ -7,6 +7,7 @@
@import url('./themes/studio-modern.css'); @import url('./themes/studio-modern.css');
@import url('./tokens.css'); @import url('./tokens.css');
@import url('./leaflet.css');
/* ── Skip to content link ─────────────────────────── */ /* ── Skip to content link ─────────────────────────── */
.skip-link { .skip-link {

View File

@@ -2,7 +2,15 @@ import { notFound } from 'next/navigation';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { readJournalEntry, listJournalEntries } from '@/lib/admin/journal-service'; import { readJournalEntry, listJournalEntries } from '@/lib/admin/journal-service';
import type { ParsedJournal } from '@/lib/journal'; import {
collectAssetIds,
mapBlockAssetIds,
type JournalBlock,
type MapPin,
type ParsedJournal,
} from '@/lib/journal';
import { entryMapPins } from '@/lib/journalMap';
import { expandAlbumBlocks } from '@/lib/journalAlbum';
import { isAdminAuthenticated } from '@/lib/admin/auth'; import { isAdminAuthenticated } from '@/lib/admin/auth';
import { isAuthenticated } from '@/lib/auth'; import { isAuthenticated } from '@/lib/auth';
import { journalNeighbours } from '@/lib/journalNav'; import { journalNeighbours } from '@/lib/journalNav';
@@ -114,7 +122,7 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
notFound(); notFound();
} }
const { frontmatter, blocks, referencedAssetIds } = entry.parsed; const { frontmatter, blocks: authoredBlocks, referencedAssetIds } = entry.parsed;
const isAuthedAdmin = await isAdminAuthenticated(); const isAuthedAdmin = await isAdminAuthenticated();
// If draft, only visible to authenticated admin // If draft, only visible to authenticated admin
@@ -164,14 +172,45 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
} }
}); });
const rawAssets = (await Promise.all(assetPromises)).filter((a): a is ImmichAsset => a !== null); const fetchedAssets = (await Promise.all(assetPromises)).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.
const albumAssetsById = new Map<string, ImmichAsset[]>();
for (const block of authoredBlocks) {
if (block.type !== 'album' || !block.albumId || albumAssetsById.has(block.albumId)) continue;
try {
albumAssetsById.set(block.albumId, await immich.getAlbumAssetsRaw(block.albumId));
} catch (error) {
// eslint-disable-next-line no-console
console.warn(`[journal] ${slug}: album ${block.albumId} could not be loaded:`, error);
}
}
const { blocks } = expandAlbumBlocks(
authoredBlocks,
(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 // EssayView drops photo blocks it cannot resolve, which is right for visitors
// but leaves no trace of *why* a photo vanished. Legacy positional references // 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, // ("1", "2") are the usual cause: they only resolved against a subpage album,
// and a standalone journal entry has none. // and a standalone journal entry has none.
if (rawAssets.length < referencedAssetIds.length) { if (fetchedAssets.length < referencedAssetIds.length) {
const resolved = new Set(rawAssets.map((a) => a.id)); const resolved = new Set(fetchedAssets.map((a) => a.id));
const missing = referencedAssetIds.filter((id) => !resolved.has(id)); const missing = referencedAssetIds.filter((id) => !resolved.has(id));
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn( console.warn(
@@ -191,6 +230,28 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
const tokenByAssetId = new Map(rawAssets.map((a) => [a.id, encodeAssetId(a.id)])); const tokenByAssetId = new Map(rawAssets.map((a) => [a.id, encodeAssetId(a.id)]));
const toToken = (assetId: string) => tokenByAssetId.get(assetId) ?? ''; 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. With the map switched off the block is dropped
// before it can reach the client; with it on, 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 (config.map) {
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,
...(frontmatter.coverAssetId ? [frontmatter.coverAssetId] : []),
]);
const essayForClient: ParsedJournal = { const essayForClient: ParsedJournal = {
// Named fields, not a spread of `frontmatter`: EssayView reads only // Named fields, not a spread of `frontmatter`: EssayView reads only
// title, subtitle, coverAssetId, author and date, but a spread put the // title, subtitle, coverAssetId, author and date, but a spread put the
@@ -203,23 +264,20 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
date: frontmatter.date, date: frontmatter.date,
coverAssetId: frontmatter.coverAssetId ? toToken(frontmatter.coverAssetId) : undefined, coverAssetId: frontmatter.coverAssetId ? toToken(frontmatter.coverAssetId) : undefined,
}, },
blocks: blocks.map((block) => { blocks: blocks.flatMap((block): JournalBlock[] => {
if (block.type === 'photo') { if (block.type === 'map') {
return { ...block, assetId: toToken(block.assetId) }; const pins = pinsByBlock.get(block);
return pins
? [{ type: 'map', caption: block.caption, line: block.line, items: [], pins }]
: [];
} }
if (block.type === 'photo-pair') { return [mapBlockAssetIds(block, toToken)];
return {
...block,
assetIds: [toToken(block.assetIds[0]), toToken(block.assetIds[1])] as [string, string],
};
}
return block;
}), }),
referencedAssetIds: rawAssets.map((a) => encodeAssetId(a.id)), referencedAssetIds: rawAssets.map((a) => encodeAssetId(a.id)),
}; };
const images: PhotoItem[] = rawAssets const images: PhotoItem[] = rawAssets
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO') .filter((a) => shownAssetIds.has(a.id) && (a.type === 'IMAGE' || a.type === 'VIDEO'))
.map((a) => { .map((a) => {
const ph = assetPlaceholder(a); const ph = assetPlaceholder(a);
const exif = const exif =

131
app/leaflet.css Normal file
View File

@@ -0,0 +1,131 @@
/* ── Leaflet: markers, popups and control overrides ────────────────
Loaded globally (see globals.css) because a map can appear on the /map
page and inside a journal entry alike. Leaflet's own stylesheet is
emitted by components/LeafletMap.tsx. */
/* ── Popups ────────────────────────────────────────────────── */
.leaflet-popup-content-wrapper {
background: var(--bg-primary, #1a1a1a) !important;
color: var(--text-primary, #fff) !important;
border-radius: var(--radius-md, 4px) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.15)) !important;
padding: 0 !important;
overflow: hidden;
}
.leaflet-popup-tip {
background: var(--bg-primary, #1a1a1a) !important;
border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.15)) !important;
border-top: none !important;
border-left: none !important;
}
.leaflet-popup-content {
margin: 0 !important;
min-width: 200px;
}
.map-popup {
font-family: var(--font-sans);
}
.map-popup__cover {
width: 100%;
height: 120px;
object-fit: cover;
display: block;
}
.map-popup__body {
padding: 0.75rem 1rem;
}
.map-popup__city {
font-family: var(--font-serif);
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 0.125rem;
color: var(--text-primary);
}
.map-popup__country {
font-family: var(--font-caption);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-secondary);
margin: 0 0 0.5rem;
}
.map-popup__count {
font-family: var(--font-caption);
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.map-popup__albums {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.map-popup__album-link {
font-size: 0.8125rem;
color: var(--accent, #e60012);
text-decoration: none;
transition: opacity 0.15s;
}
.map-popup__album-link:hover {
opacity: 0.7;
}
/* ── Custom Marker ─────────────────────────────────────────── */
.map-marker {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent, #e60012);
color: #fff;
font-family: var(--font-caption);
font-size: 0.6875rem;
font-weight: 700;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 2px solid rgba(255, 255, 255, 0.9);
cursor: pointer;
transition: transform 0.15s ease;
}
.map-marker:hover {
transform: scale(1.15);
}
/* ── Leaflet overrides ─────────────────────────────────────── */
.leaflet-control-attribution {
background: var(--bg-primary, #1a1a1a) !important;
color: var(--text-secondary, #888) !important;
font-size: 0.625rem !important;
opacity: 0.7;
}
.leaflet-control-attribution a {
color: var(--text-secondary, #888) !important;
}
.leaflet-control-zoom a {
background: var(--bg-primary, #1a1a1a) !important;
color: var(--text-primary, #fff) !important;
border-color: var(--border-subtle, rgba(128, 128, 128, 0.15)) !important;
}
.leaflet-control-zoom a:hover {
background: var(--bg-secondary, #222) !important;
}

View File

@@ -1,4 +1,6 @@
/* ── Map Page ──────────────────────────────────────────────── */ /* ── Map Page ──────────────────────────────────────────────── */
/* Marker, popup and Leaflet control styling is shared with journal map
blocks and lives in app/leaflet.css. */
.map-page { .map-page {
display: flex; display: flex;
@@ -52,130 +54,3 @@
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 0.875rem; font-size: 0.875rem;
} }
/* ── Map Popups ────────────────────────────────────────────── */
.leaflet-popup-content-wrapper {
background: var(--bg-primary, #1a1a1a) !important;
color: var(--text-primary, #fff) !important;
border-radius: var(--radius-md, 4px) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.15)) !important;
padding: 0 !important;
overflow: hidden;
}
.leaflet-popup-tip {
background: var(--bg-primary, #1a1a1a) !important;
border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.15)) !important;
border-top: none !important;
border-left: none !important;
}
.leaflet-popup-content {
margin: 0 !important;
min-width: 200px;
}
.map-popup {
font-family: var(--font-sans);
}
.map-popup__cover {
width: 100%;
height: 120px;
object-fit: cover;
display: block;
}
.map-popup__body {
padding: 0.75rem 1rem;
}
.map-popup__city {
font-family: var(--font-serif);
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 0.125rem;
color: var(--text-primary);
}
.map-popup__country {
font-family: var(--font-caption);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-secondary);
margin: 0 0 0.5rem;
}
.map-popup__count {
font-family: var(--font-caption);
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.map-popup__albums {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.map-popup__album-link {
font-size: 0.8125rem;
color: var(--accent, #e60012);
text-decoration: none;
transition: opacity 0.15s;
}
.map-popup__album-link:hover {
opacity: 0.7;
}
/* ── Custom Marker ─────────────────────────────────────────── */
.map-marker {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent, #e60012);
color: #fff;
font-family: var(--font-caption);
font-size: 0.6875rem;
font-weight: 700;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 2px solid rgba(255, 255, 255, 0.9);
cursor: pointer;
transition: transform 0.15s ease;
}
.map-marker:hover {
transform: scale(1.15);
}
/* ── Leaflet overrides ─────────────────────────────────────── */
.leaflet-control-attribution {
background: var(--bg-primary, #1a1a1a) !important;
color: var(--text-secondary, #888) !important;
font-size: 0.625rem !important;
opacity: 0.7;
}
.leaflet-control-attribution a {
color: var(--text-secondary, #888) !important;
}
.leaflet-control-zoom a {
background: var(--bg-primary, #1a1a1a) !important;
color: var(--text-primary, #fff) !important;
border-color: var(--border-subtle, rgba(128, 128, 128, 0.15)) !important;
}
.leaflet-control-zoom a:hover {
background: var(--bg-secondary, #222) !important;
}

View File

@@ -33,8 +33,9 @@ export default function MapPage() {
return ( return (
<> <>
{/* Leaflet CSS from CDN */} {/* LeafletMap emits this too, but only once /api/map has answered; on
{} this page the stylesheet should be in the server HTML so the tiles
never render before their CSS. Same href — the browser loads it once. */}
<link <link
rel="stylesheet" rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"

View File

@@ -59,7 +59,7 @@ export function Footer() {
</a> </a>
)} )}
{footer?.email && ( {footer?.email && (
<a href={`mailto:${footer.email}`} className="footer__link" aria-label="Email"> <a href={`mailto:${footer.email}`} className="footer__link" aria-label={t.common.email}>
<svg <svg
aria-hidden="true" aria-hidden="true"
width="16" width="16"
@@ -82,7 +82,7 @@ export function Footer() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="footer__link" className="footer__link"
aria-label="Website" aria-label={t.common.website}
> >
<svg <svg
aria-hidden="true" aria-hidden="true"

150
components/LeafletMap.tsx Normal file
View File

@@ -0,0 +1,150 @@
'use client';
/**
* LeafletMap — the Leaflet core shared by the /map page and journal map
* blocks: tiles, markers from props, an optional line through them, fit to
* bounds. It knows nothing about /api/map; MapView fetches and hands over.
*
* The Leaflet stylesheet is emitted here rather than by each page, so a map
* anywhere brings its own CSS. Marker and popup styling lives in
* app/leaflet.css, loaded globally.
*/
import { useEffect, useRef } from 'react';
export interface LeafletMarker {
lat: number;
lng: number;
/** Inner HTML of the marker icon — already escaped by the caller. */
html: string;
popupHtml?: string;
}
interface LeafletMapProps {
markers: LeafletMarker[];
/** Draw a dashed line through the markers in the order given. */
line?: boolean;
className?: string;
/** Cap the zoom fitBounds may pick — a single pin otherwise zooms to the rooftop. */
fitMaxZoom?: number;
onReady?: () => void;
onError?: (err: unknown) => void;
}
/** Escapes special HTML characters for Leaflet's string-based popups and icons. */
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
const LEAFLET_CSS_HREF = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
const LEAFLET_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
export function LeafletMap({
markers,
line = false,
className = 'map-container',
fitMaxZoom,
onReady,
onError,
}: LeafletMapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
// Callers rebuild the markers array on every render; the map is only
// rebuilt when its content changes.
const markersKey = JSON.stringify(markers);
const markersRef = useRef(markers);
markersRef.current = markers;
const onReadyRef = useRef(onReady);
onReadyRef.current = onReady;
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
useEffect(() => {
let cancelled = false;
async function init() {
const L = (await import('leaflet')).default;
if (cancelled || !containerRef.current || mapRef.current) return;
const map = L.map(containerRef.current, { zoomControl: true, attributionControl: true });
mapRef.current = map;
// Dark-themed tiles (CartoDB Dark Matter)
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd',
maxZoom: 19,
}).addTo(map);
const current = markersRef.current;
if (current.length === 0) {
// Nothing to place — world view
map.setView([20, 0], 2);
} else {
const group = L.featureGroup(
current.map((m) => {
const marker = L.marker([m.lat, m.lng], {
icon: L.divIcon({
className: '',
html: m.html,
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -20],
}),
});
if (m.popupHtml) marker.bindPopup(m.popupHtml, { maxWidth: 260, minWidth: 200 });
return marker;
}),
).addTo(map);
if (line && current.length > 1) {
const accent =
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() ||
'#e60012';
L.polyline(
current.map((m) => [m.lat, m.lng] as [number, number]),
{ color: accent, weight: 2, opacity: 0.75, dashArray: '4 6' },
).addTo(map);
}
map.fitBounds(group.getBounds().pad(0.15), fitMaxZoom ? { maxZoom: fitMaxZoom } : {});
}
// Force Leaflet to recalculate the tile grid once laid out
setTimeout(() => map.invalidateSize(), 100);
onReadyRef.current?.();
}
init().catch((err) => {
console.error('[Map] Init error:', err);
if (!cancelled) onErrorRef.current?.(err);
});
return () => {
cancelled = true;
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
};
}, [markersKey, line, fitMaxZoom]);
return (
<>
<link
rel="stylesheet"
href={LEAFLET_CSS_HREF}
integrity={LEAFLET_CSS_INTEGRITY}
crossOrigin=""
/>
<div ref={containerRef} className={className} />
</>
);
}

View File

@@ -1,22 +1,13 @@
'use client'; 'use client';
/** /**
* MapView — client component that initializes Leaflet, fetches /api/map, * MapView — fetches /api/map and renders its location-level markers with
* and renders location-level markers with themed popups. * themed popups through LeafletMap, which owns the Leaflet setup.
*/ */
import { useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useDictionary } from './I18nProvider'; import { useDictionary } from './I18nProvider';
import { LeafletMap, escapeHtml, type LeafletMarker } from './LeafletMap';
/** Escapes special HTML characters to prevent XSS in Leaflet popup templates. */
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
interface MapLocationPublic { interface MapLocationPublic {
city: string; city: string;
@@ -30,21 +21,14 @@ interface MapLocationPublic {
export function MapView() { export function MapView() {
const t = useDictionary(); const t = useDictionary();
const containerRef = useRef<HTMLDivElement>(null); const [locations, setLocations] = useState<MapLocationPublic[] | null>(null);
const mapRef = useRef<unknown>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
async function init() { async function load() {
// Dynamically import Leaflet (client-only)
const L = (await import('leaflet')).default;
if (cancelled || !containerRef.current || mapRef.current) return;
// Fetch map data
const res = await fetch('/api/map'); const res = await fetch('/api/map');
if (!res.ok) { if (!res.ok) {
let detail = ''; let detail = '';
@@ -54,48 +38,32 @@ export function MapView() {
/* ignore */ /* ignore */
} }
console.error('[Map] API error', res.status, detail); console.error('[Map] API error', res.status, detail);
if (!cancelled) {
setError(t.map.loadFailed(res.status)); setError(t.map.loadFailed(res.status));
setLoading(false); setLoading(false);
}
return; return;
} }
const locations: MapLocationPublic[] = await res.json(); const data: MapLocationPublic[] = await res.json();
if (!cancelled) setLocations(data);
}
if (cancelled) return; load().catch((err) => {
console.error('[Map] Init error:', err);
// Initialize map if (!cancelled) {
const map = L.map(containerRef.current, { setError(t.map.initFailed);
zoomControl: true,
attributionControl: true,
});
mapRef.current = map;
// Dark-themed tiles (CartoDB Dark Matter)
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd',
maxZoom: 19,
}).addTo(map);
if (locations.length === 0) {
// No geotagged photos — show world view
map.setView([20, 0], 2);
setLoading(false); setLoading(false);
return;
} }
// Create markers with custom HTML icons
const markers: L.Marker[] = [];
for (const loc of locations) {
const icon = L.divIcon({
className: '',
html: `<div class="map-marker">${loc.photoCount}</div>`,
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -20],
}); });
return () => {
cancelled = true;
};
}, [t]);
const markers = useMemo<LeafletMarker[]>(
() =>
(locations ?? []).map((loc) => {
const albumLinks = loc.albums const albumLinks = loc.albums
.map( .map(
(a) => (a) =>
@@ -111,7 +79,11 @@ export function MapView() {
const heading = loc.city || loc.country; const heading = loc.city || loc.country;
const subheading = loc.city ? loc.country : ''; const subheading = loc.city ? loc.country : '';
const popupHtml = ` return {
lat: loc.lat,
lng: loc.lng,
html: `<div class="map-marker">${loc.photoCount}</div>`,
popupHtml: `
<div class="map-popup"> <div class="map-popup">
<img src="${escapeHtml(loc.coverUrl)}" alt="${escapeHtml(heading)}" class="map-popup__cover" loading="lazy" /> <img src="${escapeHtml(loc.coverUrl)}" alt="${escapeHtml(heading)}" class="map-popup__cover" loading="lazy" />
<div class="map-popup__body"> <div class="map-popup__body">
@@ -121,43 +93,11 @@ export function MapView() {
<div class="map-popup__albums">${albumLinks}</div> <div class="map-popup__albums">${albumLinks}</div>
</div> </div>
</div> </div>
`; `,
const marker = L.marker([loc.lat, loc.lng], { icon })
.addTo(map)
.bindPopup(popupHtml, { maxWidth: 260, minWidth: 200 });
markers.push(marker);
}
// Fit bounds to show all markers
if (markers.length > 0) {
const group = L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.15));
}
// Force Leaflet to recalculate tile grid
setTimeout(() => map.invalidateSize(), 100);
setLoading(false);
}
init().catch((err) => {
console.error('[Map] Init error:', err);
if (!cancelled) {
setError(t.map.initFailed);
setLoading(false);
}
});
return () => {
cancelled = true;
if (mapRef.current) {
(mapRef.current as L.Map).remove();
mapRef.current = null;
}
}; };
}, [t]); }),
[locations, t],
);
if (error) { if (error) {
return ( return (
@@ -213,7 +153,18 @@ export function MapView() {
`}</style> `}</style>
</div> </div>
)} )}
<div ref={containerRef} className="map-container" /> {locations ? (
<LeafletMap
markers={markers}
onReady={() => setLoading(false)}
onError={() => {
setError(t.map.initFailed);
setLoading(false);
}}
/>
) : (
<div className="map-container" />
)}
</div> </div>
); );
} }

View File

@@ -15,7 +15,11 @@ interface ProofingContextType {
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
getProofingUrl: () => string; getProofingUrl: () => string;
getFormattedList: () => string; getFormattedList: () => string;
/** Selected asset tokens in album order — the payload for a ZIP download. */
getSelectedTokens: () => string[];
allowMailto: boolean; allowMailto: boolean;
/** The ZIP endpoint when the album offers downloads; undefined otherwise. */
downloadArchiveUrl?: string;
} }
const ProofingContext = createContext<ProofingContextType | null>(null); const ProofingContext = createContext<ProofingContextType | null>(null);
@@ -25,11 +29,13 @@ export function ProofingProvider({
albumTokens = [], albumTokens = [],
albumName = 'Gallery', albumName = 'Gallery',
allowMailto = true, allowMailto = true,
downloadArchiveUrl,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
albumTokens?: string[]; albumTokens?: string[];
albumName?: string; albumName?: string;
allowMailto?: boolean; allowMailto?: boolean;
downloadArchiveUrl?: string;
}) { }) {
const t = useDictionary(); const t = useDictionary();
const [favorites, setFavorites] = useState<Set<string>>(new Set()); const [favorites, setFavorites] = useState<Set<string>>(new Set());
@@ -121,6 +127,8 @@ export function ProofingProvider({
return url.toString(); return url.toString();
}; };
const getSelectedTokens = () => albumTokens.filter((token) => favorites.has(token));
const getFormattedList = () => { const getFormattedList = () => {
const selectedIndices: number[] = []; const selectedIndices: number[] = [];
albumTokens.forEach((token, index) => { albumTokens.forEach((token, index) => {
@@ -149,7 +157,9 @@ export function ProofingProvider({
setIsModalOpen, setIsModalOpen,
getProofingUrl, getProofingUrl,
getFormattedList, getFormattedList,
getSelectedTokens,
allowMailto, allowMailto,
downloadArchiveUrl,
}} }}
> >
{children} {children}

View File

@@ -10,6 +10,13 @@ export function ProofingModal() {
const t = useDictionary(); const t = useDictionary();
const proofing = useProofing(); const proofing = useProofing();
const [copiedState, setCopiedState] = useState<'none' | 'link' | 'list'>('none'); const [copiedState, setCopiedState] = useState<'none' | 'link' | 'list'>('none');
/**
* What to show for copying by hand. `navigator.clipboard` only exists in a
* secure context, and a self-hosted portfolio reached over plain http on a
* LAN is not one — the buttons used to throw and appear to do nothing. The
* lightbox's permalink falls back the same way.
*/
const [manualCopy, setManualCopy] = useState<'link' | 'list' | null>(null);
/* Before the early `return null`: hooks must not run conditionally. /* Before the early `return null`: hooks must not run conditionally.
`proofing` can be null, hence the optional calls. */ `proofing` can be null, hence the optional calls. */
@@ -24,25 +31,37 @@ export function ProofingModal() {
setIsModalOpen, setIsModalOpen,
getProofingUrl, getProofingUrl,
getFormattedList, getFormattedList,
getSelectedTokens,
clearFavorites, clearFavorites,
allowMailto, allowMailto,
downloadArchiveUrl,
} = proofing; } = proofing;
const handleCopyLink = () => { // What the archive would actually receive. Gating on this rather than on
const url = getProofingUrl(); // `favorites.size` means a favourite left over from another album (they share
navigator.clipboard.writeText(url).then(() => { // the provider's storage key when no `albumName` is passed) can never light up
setCopiedState('link'); // a button that would post an empty selection and 404.
const selectedCount = getSelectedTokens().length;
/** Copy to the clipboard, or show the text to copy by hand where it is unavailable. */
const copy = (kind: 'link' | 'list', text: string) => {
if (!navigator.clipboard) {
setManualCopy(kind);
return;
}
navigator.clipboard.writeText(text).then(
() => {
setManualCopy(null);
setCopiedState(kind);
setTimeout(() => setCopiedState('none'), 2000); setTimeout(() => setCopiedState('none'), 2000);
}); },
() => setManualCopy(kind),
);
}; };
const handleCopyList = () => { const handleCopyLink = () => copy('link', getProofingUrl());
const list = getFormattedList();
navigator.clipboard.writeText(list).then(() => { const handleCopyList = () => copy('list', getFormattedList());
setCopiedState('list');
setTimeout(() => setCopiedState('none'), 2000);
});
};
const handleMailto = () => { const handleMailto = () => {
const subject = encodeURIComponent(t.proofing.mailSubject(favorites.size)); const subject = encodeURIComponent(t.proofing.mailSubject(favorites.size));
@@ -50,6 +69,31 @@ export function ProofingModal() {
window.location.href = `mailto:?subject=${subject}&body=${body}`; window.location.href = `mailto:?subject=${subject}&body=${body}`;
}; };
const handleDownloadSelection = () => {
if (!downloadArchiveUrl) return;
const tokens = getSelectedTokens();
if (tokens.length === 0) return;
// A form POST, not a fetch: the browser streams the response straight to
// disk, so a large selection never has to fit in memory — a phone cannot
// hold a whole ZIP in a blob. The route answers with
// `Content-Disposition: attachment`, so the page stays where it was.
const form = document.createElement('form');
form.method = 'POST';
form.action = downloadArchiveUrl;
form.style.display = 'none';
for (const token of tokens) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'assets';
input.value = token;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
form.remove();
};
return ( return (
<div <div
className="proofing-modal-overlay" className="proofing-modal-overlay"
@@ -124,9 +168,11 @@ export function ProofingModal() {
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{downloadArchiveUrl && (
<button <button
type="button" type="button"
onClick={handleCopyLink} onClick={handleDownloadSelection}
disabled={selectedCount === 0}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -138,6 +184,49 @@ export function ProofingModal() {
color: '#fff', color: '#fff',
border: 'none', border: 'none',
fontWeight: 500, fontWeight: 500,
cursor: selectedCount === 0 ? 'not-allowed' : 'pointer',
opacity: selectedCount === 0 ? 0.6 : 1,
}}
>
<svg
aria-hidden="true"
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{t.proofing.downloadSelected}
</button>
)}
{/* The primary action is the download when there is one; otherwise
copying the link stays the accent button it always was. */}
<button
type="button"
onClick={handleCopyLink}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
padding: '0.75rem 1rem',
borderRadius: 'var(--radius-sm, 6px)',
...(downloadArchiveUrl
? {
background: 'rgba(255,255,255,0.1)',
color: 'inherit',
border: '1px solid rgba(255,255,255,0.15)',
}
: { background: 'var(--accent, #e60012)', color: '#fff', border: 'none' }),
fontWeight: 500,
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
@@ -180,6 +269,33 @@ export function ProofingModal() {
)} )}
</button> </button>
{manualCopy && (
<div role="status" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<label htmlFor="proofing-manual-copy" style={{ fontSize: '0.85rem', opacity: 0.8 }}>
{manualCopy === 'link' ? t.proofing.copyManualLink : t.proofing.copyManualList}
</label>
<textarea
id="proofing-manual-copy"
readOnly
autoFocus
rows={manualCopy === 'link' ? 2 : 4}
value={manualCopy === 'link' ? getProofingUrl() : getFormattedList()}
onFocus={(e) => e.currentTarget.select()}
style={{
width: '100%',
padding: '0.5rem 0.75rem',
borderRadius: 'var(--radius-sm, 6px)',
background: 'var(--bg-card-hover)',
color: 'inherit',
border: '1px solid var(--border-subtle)',
font: 'inherit',
fontSize: '0.85rem',
resize: 'vertical',
}}
/>
</div>
)}
{allowMailto && ( {allowMailto && (
<button <button
type="button" type="button"

View File

@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import { ProofingModal } from '../ProofingModal';
/**
* `navigator.clipboard` only exists in a secure context. A portfolio reached
* over plain http on a LAN is not one, and both copy buttons used to throw on
* the missing API and appear dead. They now show the text to copy by hand.
*/
const URL_ = 'http://192.168.1.20:7211/deutschland/kloster-chorin?fav=abc';
const LIST = '#1, #3';
vi.mock('../ProofingContext', () => ({
useProofing: () => ({
favorites: new Set(['a', 'b']),
isModalOpen: true,
setIsModalOpen: vi.fn(),
getProofingUrl: () => URL_,
getFormattedList: () => LIST,
getSelectedTokens: () => ['a', 'b'],
clearFavorites: vi.fn(),
allowMailto: false,
downloadArchiveUrl: undefined,
}),
}));
const setClipboard = (value: unknown) =>
Object.defineProperty(navigator, 'clipboard', { value, configurable: true });
afterEach(() => {
cleanup();
setClipboard(undefined);
});
describe('ProofingModal copy buttons', () => {
it('shows the link to copy by hand when there is no clipboard (plain http)', () => {
setClipboard(undefined);
render(<ProofingModal />);
fireEvent.click(screen.getByText('Copy Shareable Link'));
expect((screen.getByLabelText('Copy this link') as HTMLTextAreaElement).value).toBe(URL_);
});
it('shows the list to copy by hand when there is no clipboard', () => {
setClipboard(undefined);
render(<ProofingModal />);
fireEvent.click(screen.getByText('Copy Text List (#1, #2...)'));
expect((screen.getByLabelText('Copy this list') as HTMLTextAreaElement).value).toBe(LIST);
});
it('falls back as well when the browser refuses the write', async () => {
setClipboard({ writeText: vi.fn(() => Promise.reject(new Error('NotAllowedError'))) });
render(<ProofingModal />);
fireEvent.click(screen.getByText('Copy Shareable Link'));
await waitFor(() => expect(screen.getByLabelText('Copy this link')).toBeTruthy());
});
it('copies silently where the clipboard works', async () => {
const writeText = vi.fn(() => Promise.resolve());
setClipboard({ writeText });
render(<ProofingModal />);
fireEvent.click(screen.getByText('Copy Shareable Link'));
await waitFor(() => expect(screen.getByText('Link Copied!')).toBeTruthy());
expect(writeText).toHaveBeenCalledWith(URL_);
expect(screen.queryByLabelText('Copy this link')).toBeNull();
});
});

View File

@@ -13,8 +13,13 @@ Photos are referenced by their Immich asset id, and those ids are specific to
your server, so this template ships without any. Add them in the studio via your server, so this template ships without any. Add them in the studio via
Add Block → Photo, where the picker fills the id in for you. Written by hand it Add Block → Photo, where the picker fills the id in for you. Written by hand it
is `![<asset-id>:fullbleed](Caption)` for a single photo — `:wide` or no suffix is `![<asset-id>:fullbleed](Caption)` for a single photo — `:wide` or no suffix
give the narrower widths — and `![<asset-id>, <asset-id>](Caption)` for a give the narrower widths — `![<asset-id>, <asset-id>](Caption)` for a
side-by-side pair. side-by-side pair, and three or more ids for a grid. A `::facts` paragraph of
`Label: Value` lines renders as a definition list, a `::map Caption`
paragraph draws a map from the lines under it — `Label: lat, lng` for a
point, `photo: <asset-id>` for a photo, `photos: all` for every geotagged
photo of the entry — and `::album <album-id>` with an optional `count:`
inserts photos from an album without picking each (see docs/journal.md).
# Into the Wilderness # Into the Wilderness

View File

@@ -95,7 +95,7 @@ Subpages, albums and sections are reordered in place; the order in the panel is
The **Journal Studio** — a split-screen editor with the blocks on the left and a live preview of the real page on the right, separated by a divider you can drag with mouse, keyboard or touch. The **Journal Studio** — a split-screen editor with the blocks on the left and a live preview of the real page on the right, separated by a divider you can drag with mouse, keyboard or touch.
Blocks are added from a menu (heading, text, quote, photo, photo pair), reordered, and deleted; each carries a type chip so long entries stay scannable. Photos are inserted through the asset picker, which fills in the Immich asset UUID for you. The frontmatter — cover image, title, subtitle, author, date, draft flag, password — is edited as form fields. Blocks are added from a menu (heading, text, quote, facts, photo, photo pair, photo grid, album, map), reordered, and deleted; each carries a type chip so long entries stay scannable. Photos are inserted through the asset picker, which fills in the Immich asset UUID for you. A new entry can start from a template — Wedding, Hiking, Travel, Birthday, Portrait Session, Behind the Scenes, Pets, New Series — chosen in the create dialog; the blocks arrive with placeholder text and empty photo tiles to fill. The frontmatter — cover image, title, subtitle, author, date, draft flag, password — is edited as form fields.
Saving writes plain Markdown to `content/journal/<slug>.md`, which can equally be edited by hand. Saving writes plain Markdown to `content/journal/<slug>.md`, which can equally be edited by hand.

View File

@@ -192,3 +192,11 @@ photos.example.com {
> limits on the password endpoints. See > limits on the password endpoints. See
> [Trusted Proxies](gallery-config.md#trusted-proxies) for the setting and a > [Trusted Proxies](gallery-config.md#trusted-proxies) for the setting and a
> matching nginx config. > matching nginx config.
Login cookies (admin session and gallery passwords) are marked `Secure` when the
request arrived over HTTPS, which the app learns from `X-Forwarded-Proto`. Caddy
and Traefik send it on their own; nginx needs the
`proxy_set_header X-Forwarded-Proto $scheme;` line from the config linked above.
Opening the app over plain HTTP, e.g. `http://server:7211` on a home network,
works too: the cookies are then set without `Secure`, since a browser would
discard them otherwise.

View File

@@ -203,6 +203,7 @@ albums:
password: 'secure-password' password: 'secure-password'
heroImage: 'asset-uuid' heroImage: 'asset-uuid'
sort: filename sort: filename
download: true
``` ```
| Key | Description | | Key | Description |
@@ -215,6 +216,7 @@ albums:
| `assetOrder` | Pinned asset UUIDs for `sort: manual` | | `assetOrder` | Pinned asset UUIDs for `sort: manual` |
| `grid` | **Experimental.** Grid override for this album only | | `grid` | **Experimental.** Grid override for this album only |
| `coverPosition` | **Experimental.** Focal point for the cover crop | | `coverPosition` | **Experimental.** Focal point for the cover crop |
| `download` | Offer originals for download — see [Originals download](#originals-download) |
### Per-album grid (experimental) ### Per-album grid (experimental)
@@ -244,6 +246,29 @@ albums:
Useful when the automatic centre crop cuts off a horizon or a face. Useful when the automatic centre crop cuts off a horizon or a face.
### Originals download
Opt in per album with `download: true`. Off everywhere by default, and opt-in
on purpose: a public portfolio should not start handing out full-resolution
originals because one client gallery needed to.
```yaml
albums:
- 'album-uuid':
download: true
```
Once enabled, visitors get a **Download album** link in the album header (every
original as a ZIP), and — where [client proofing](#client-proofing) is on — a
**Download selected (.zip)** action in the export dialog that zips just the
favourited photos.
The download route re-checks the allowlist, the `download` flag, every password
gate on a route to the album, and that each asset really belongs to that album,
so an edited URL cannot reach anything else. The ZIP keeps the original
filenames (deduplicated on collision) and is streamed, so album size does not
drive memory use.
## Password Protection ## Password Protection
A password can be set on the whole site, on a subpage, on a single album, and on A password can be set on the whole site, on a subpage, on a single album, and on
@@ -567,6 +592,10 @@ The selection is encoded into the URL as a compact bitmask and mirrored into
`localStorage`, so **nothing is stored server-side** and a set of picks can be `localStorage`, so **nothing is stored server-side** and a set of picks can be
shared, bookmarked, or sent back as a plain link. shared, bookmarked, or sent back as a plain link.
When an album also opts into [originals download](#originals-download), the
export dialog gains a **Download selected (.zip)** action, and the album header
a **Download album** link.
Configured in `settings.yaml`: Configured in `settings.yaml`:
```yaml ```yaml

View File

@@ -137,9 +137,101 @@ real aspect ratios rather than forced equal:
![asset-uuid-left, asset-uuid-right](Optional caption) ![asset-uuid-left, asset-uuid-right](Optional caption)
``` ```
Three or more render as a grid — rows of up to three, each row justified like
a pair (one shared height, widths from the real ratios, nothing cropped),
stacked on phones:
```markdown
![uuid-1, uuid-2, uuid-3, uuid-4](Optional caption)
```
Photos in an entry open in the same lightbox as the rest of the site, with Photos in an entry open in the same lightbox as the rest of the site, with
keyboard and swipe navigation. keyboard and swipe navigation.
### Facts
A `::facts` line followed by `Label: Value` lines in the same paragraph
renders as a compact definition list — distance and elevation for a hike,
date and venue for a wedding:
```markdown
::facts
Distance: 21 km
Elevation: 1,240 m
Start: 08:30
```
The first colon splits label from value, so a time keeps its `:`. Values take
the same inline markdown as text. Lines without a colon are ignored.
### Map
A `::map` line, with an optional caption after it, starts a map. The lines
after it say what is on it — numbered pins in the order written, joined by
a line:
```markdown
::map Busan → Seoul
Busan harbour: 35.098, 129.036
photo: 8f1c0e2a-…
photos: all
Seoul: 37.566, 126.978
line: off
```
| Line | Puts on the map |
| ----------------- | ------------------------------------------------------------------------------------------------- |
| `Label: lat, lng` | A point with a label, exactly where you typed it. |
| `lat, lng` | A point without a label. |
| `photo: <uuid>` | One of the entry's photos, placed by its GPS. Several ids may follow, comma-separated. |
| `photos: all` | Every geotagged photo in the entry, in the order they appear — skipping ids already listed above. |
| `line: off` | No connecting line. The default draws one when there are two or more pins. |
A map with no lines renders nothing. `photo`, `photos` and `line` are reserved
as labels; a coordinate pair that does not parse, or lies outside ±90 / ±180,
is ignored (the Studio warns before you save).
Typed points are published as typed — they are your content. Photo pins are
derived from EXIF, so they follow each album's `location:` setting the way
the map page does: `hidden` and `country` add no pin, `city` snaps to the
same 5 km grid, and where an album allows exact positions a journal pin is
still snapped to a 1 km grid — the map page shows the mean of an album's
photos in a city, never a single photo, and a story map should not be the
first surface that places one photo at its doorstep. Photo pins are computed
when the page renders; the Studio's preview shows typed points right away and
leaves photo pins for the live page.
The block renders only while `map: true` is set in `settings.yaml`, typed
points included — that setting also means "no map tiles from CartoDB for my
visitors".
### Album
"The first twelve of the Seoul album", without picking each photo:
```markdown
::album 371336b4-eb59-409c-b24e-a613f81ede5a
count: 12
skip: 0
layout: grid
caption: Twelve from Seoul
```
| Line | Meaning |
| -------------- | ------------------------------------------------------------------------------- |
| `::album <id>` | The Immich album id — the Studio's album picker fills it in. |
| `count: N` | How many photos. Default: the whole album. |
| `skip: N` | Offset, so two blocks can split one album. Default 0. |
| `layout: …` | `grid` (rows of three, default), `pairs` (rows of two) or `wide` (one per row). |
| `caption: …` | One caption under the set. |
The block is expanded into ordinary photo blocks when the page renders, so
the lightbox and everything else work as for hand-picked photos. Photos come
in the album's order; if the gallery pins a manual `assetOrder` for that
album, those come first. The album need not be published in `gallery.yaml` —
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.
<p align="center"> <p align="center">
<img src="screenshots/journal-entry.png" width="98%" alt="A rendered journal entry with heading, body text, a fullbleed photo and a quote" /> <img src="screenshots/journal-entry.png" width="98%" alt="A rendered journal entry with heading, body text, a fullbleed photo and a quote" />
</p> </p>
@@ -147,7 +239,10 @@ keyboard and swipe navigation.
> [!TIP] > [!TIP]
> Writing asset UUIDs by hand is tedious. In the [Journal Studio](#journal-studio), > Writing asset UUIDs by hand is tedious. In the [Journal Studio](#journal-studio),
> **Add Block → Photo** opens the asset picker and fills the UUID in for you. > **Add Block → Photo**, **2-Photo Pair** and **Photo Grid** open the asset
> picker and fill the UUIDs in for you; **Facts** and **Map** have their own
> small forms. New entries can also start from a template — Wedding, Hiking,
> Travel and the others — instead of an empty editor.
## Drafts ## Drafts

95
e2e/proofing.spec.ts Normal file
View File

@@ -0,0 +1,95 @@
import { test, expect, type Page } from '@playwright/test';
/**
* Client proofing + download e2e.
*
* Two independent capabilities, two albums:
*
* - Proofing (hearts, the selection bar, the send-off modal) is on by default
* for every album, so it is tested against any published album.
* - Downloading (the "Download album" header link and the "Download selected"
* modal action) only exists on an album that opted in with `download: true`
* in gallery.yaml, so those tests run against a separate album and skip
* cleanly when it is not configured.
*
* Override the URLs to match a real deployment:
* E2E_ALBUM_URL an album with photos (proofing tests)
* E2E_DOWNLOAD_ALBUM_URL an album with `download: true`
*/
const ALBUM_URL = process.env.E2E_ALBUM_URL || '/deutschland/kloster-chorin';
const DOWNLOAD_ALBUM_URL = process.env.E2E_DOWNLOAD_ALBUM_URL || ALBUM_URL;
const stickyBar = (page: Page) => page.locator('.proofing-sticky-bar');
const modalCard = (page: Page) => page.locator('.proofing-modal-card');
/** Favourite the first photo and open the send-off modal via the sticky bar. */
async function favouriteAndOpenModal(page: Page) {
const fav = page.locator('.photo-grid__fav-btn').first();
await fav.scrollIntoViewIfNeeded();
await fav.click();
await expect(stickyBar(page)).toBeVisible();
// Second button in the bar is "Share & Export"; the first is the filter.
await stickyBar(page).locator('button').nth(1).click();
await expect(modalCard(page)).toBeVisible();
}
test.describe('Proofing', () => {
test('favouriting a photo surfaces the selection bar', async ({ page }) => {
await page.goto(ALBUM_URL);
const fav = page.locator('.photo-grid__fav-btn').first();
test.skip((await fav.count()) === 0, 'proofing disabled for this album');
await fav.click();
await expect(stickyBar(page)).toBeVisible();
});
test('the send-off modal opens with a selection and clears again', async ({ page }) => {
await page.goto(ALBUM_URL);
const fav = page.locator('.photo-grid__fav-btn').first();
test.skip((await fav.count()) === 0, 'proofing disabled for this album');
await favouriteAndOpenModal(page);
await expect(modalCard(page).locator('h3')).toBeVisible();
// Clear is guarded by a native confirm(); accept it.
page.once('dialog', (dialog) => dialog.accept());
await modalCard(page)
.getByRole('button', { name: /clear selection|auswahl löschen/i })
.click();
await expect(modalCard(page)).toHaveCount(0);
await expect(stickyBar(page)).toHaveCount(0);
});
});
test.describe('Downloads', () => {
test('the header offers a whole-album download', async ({ page }) => {
await page.goto(DOWNLOAD_ALBUM_URL);
const link = page.locator('.album-header__download');
test.skip((await link.count()) === 0, 'album does not opt into downloads');
const [download] = await Promise.all([page.waitForEvent('download'), link.click()]);
expect(download.suggestedFilename()).toMatch(/\.zip$/i);
});
test('the modal downloads just the selected photos', async ({ page }) => {
await page.goto(DOWNLOAD_ALBUM_URL);
const fav = page.locator('.photo-grid__fav-btn').first();
test.skip((await fav.count()) === 0, 'proofing disabled for this album');
await favouriteAndOpenModal(page);
const downloadButton = modalCard(page).getByRole('button', {
name: /download selected|auswahl herunterladen/i,
});
test.skip((await downloadButton.count()) === 0, 'album does not opt into downloads');
const [download] = await Promise.all([page.waitForEvent('download'), downloadButton.click()]);
expect(download.suggestedFilename()).toMatch(/\.zip$/i);
});
});

View File

@@ -45,6 +45,7 @@ import {
isAlbumReachable, isAlbumReachable,
withoutLockedAlbums, withoutLockedAlbums,
findSubpageBySlug, findSubpageBySlug,
isHttpsRequest,
} from '@/lib/auth'; } from '@/lib/auth';
describe('findSubpageBySlug', () => { describe('findSubpageBySlug', () => {
@@ -286,3 +287,40 @@ describe('withoutLockedAlbums', () => {
expect(withoutLockedAlbums([LOCKED, OPEN], getCookie)).toEqual([LOCKED, OPEN]); expect(withoutLockedAlbums([LOCKED, OPEN], getCookie)).toEqual([LOCKED, OPEN]);
}); });
}); });
/**
* The `Secure` flag follows the request, not NODE_ENV (#664): the Docker image
* always runs in production, and a browser drops a `Secure` cookie set over
* plain HTTP — the password was accepted and every next request answered 401.
*/
describe('isHttpsRequest', () => {
const req = (url: string, proto?: string) =>
new Request(url, { headers: proto ? { 'x-forwarded-proto': proto } : {} });
it('reads the scheme of a direct request', () => {
expect(isHttpsRequest(req('https://folio.example/api/auth'))).toBe(true);
expect(isHttpsRequest(req('http://192.168.1.10:7211/api/auth'))).toBe(false);
});
it('prefers the scheme a reverse proxy reports', () => {
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'https'))).toBe(true);
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'HTTPS'))).toBe(true);
expect(isHttpsRequest(req('https://folio.example/api/auth', 'http'))).toBe(false);
});
it('takes the first entry of a chained header', () => {
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'https, http'))).toBe(true);
});
});
describe('authenticate — Secure flag', () => {
it('marks the cookie Secure by default', async () => {
expect(await authenticate('private', 'secret123')).toMatch(/; Secure$/);
});
it('leaves Secure off for a plain-HTTP visitor', async () => {
const cookie = await authenticate('private', 'secret123', 'subpage', false);
expect(cookie).toContain('lb_auth_private=');
expect(cookie).not.toContain('Secure');
});
});

View File

@@ -90,6 +90,7 @@ describe('dictionary parity', () => {
'lightbox.iso', 'lightbox.iso',
'lightbox.copyLinkShort', 'lightbox.copyLinkShort',
'lightbox.downloadShort', 'lightbox.downloadShort',
'common.website',
'subpage.coverAria', 'subpage.coverAria',
]); ]);

View File

@@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest';
import { parseJournalMarkdown, serializeJournalMarkdown, collectAssetIds } from '../journal';
import type { JournalBlock } from '../journal';
import { expandAlbumBlocks, orderAlbumAssets } from '../journalAlbum';
const block = (md: string) => parseJournalMarkdown(md).blocks[0];
describe('album block syntax', () => {
it('parses the id and every option', () => {
expect(
block(
['::album abc-123', 'count: 12', 'skip: 2', 'layout: pairs', 'caption: The **day**'].join(
'\n',
),
),
).toEqual({
type: 'album',
albumId: 'abc-123',
count: 12,
skip: 2,
layout: 'pairs',
caption: 'The <strong>day</strong>',
});
});
it('defaults to grid, all photos, no skip — and allows an empty id placeholder', () => {
expect(block('::album')).toEqual({ type: 'album', albumId: '', layout: 'grid' });
expect(block('::album abc')).toEqual({ type: 'album', albumId: 'abc', layout: 'grid' });
});
it('ignores malformed options', () => {
expect(block('::album abc\ncount: many\nlayout: mosaic\nskip: -1')).toEqual({
type: 'album',
albumId: 'abc',
layout: 'grid',
});
});
it('round-trips byte-stable and stays out of the asset ids', () => {
const md = ['::album abc-123', 'count: 12', 'skip: 2', 'layout: wide', 'caption: Twelve'].join(
'\n',
);
const parsed = parseJournalMarkdown(md);
expect(serializeJournalMarkdown(parsed)).toBe(md);
expect(serializeJournalMarkdown(parseJournalMarkdown('::album'))).toBe('::album');
expect(collectAssetIds(parsed.blocks)).toEqual([]);
});
});
const assets = (n: number, type = 'IMAGE') =>
Array.from({ length: n }, (_, i) => ({ id: `p${i + 1}`, type }));
describe('expandAlbumBlocks', () => {
const lookup = (id: string) => (id === 'alb' ? assets(7) : undefined);
const albumBlock = (
extra: Partial<Extract<JournalBlock, { type: 'album' }>> = {},
): JournalBlock => ({
type: 'album',
albumId: 'alb',
layout: 'grid',
...extra,
});
it('turns an album into one grid, keeping the surrounding blocks', () => {
const before: JournalBlock = { type: 'heading', level: 2, text: 'Party' };
const { blocks, albumAssetIds } = expandAlbumBlocks(
[before, albumBlock({ caption: 'All of it' })],
lookup,
);
expect(blocks).toEqual([
before,
{
type: 'photo-grid',
assetIds: ['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7'],
caption: 'All of it',
},
]);
expect(albumAssetIds).toEqual(['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7']);
});
it('slices with count and skip', () => {
const { blocks } = expandAlbumBlocks([albumBlock({ count: 3, skip: 2 })], lookup);
expect(blocks).toEqual([{ type: 'photo-grid', assetIds: ['p3', 'p4', 'p5'] }]);
});
it('lays out pairs with an odd tail and puts the caption last', () => {
const { blocks } = expandAlbumBlocks(
[albumBlock({ count: 5, layout: 'pairs', caption: 'c' })],
lookup,
);
expect(blocks).toEqual([
{ type: 'photo-pair', assetIds: ['p1', 'p2'] },
{ type: 'photo-pair', assetIds: ['p3', 'p4'] },
{ type: 'photo', assetId: 'p5', layout: 'contained', caption: 'c' },
]);
});
it('lays out wide as one photo per row', () => {
const { blocks } = expandAlbumBlocks([albumBlock({ count: 2, layout: 'wide' })], lookup);
expect(blocks).toEqual([
{ type: 'photo', assetId: 'p1', layout: 'wide' },
{ type: 'photo', assetId: 'p2', layout: 'wide' },
]);
});
it('falls back to a pair or a single photo when a grid has too few', () => {
expect(expandAlbumBlocks([albumBlock({ count: 2 })], lookup).blocks).toEqual([
{ type: 'photo-pair', assetIds: ['p1', 'p2'] },
]);
expect(expandAlbumBlocks([albumBlock({ count: 1 })], lookup).blocks).toEqual([
{ type: 'photo', assetId: 'p1', layout: 'contained' },
]);
});
it('drops an empty id, an unknown album and an empty slice', () => {
expect(expandAlbumBlocks([albumBlock({ albumId: '' })], lookup).blocks).toEqual([]);
expect(expandAlbumBlocks([albumBlock({ albumId: 'nope' })], lookup).blocks).toEqual([]);
expect(expandAlbumBlocks([albumBlock({ skip: 99 })], lookup).blocks).toEqual([]);
});
it('puts the gallery manual order first, then the album order', () => {
const { blocks } = expandAlbumBlocks([albumBlock({ count: 4 })], lookup, {
alb: ['p6', 'p2', 'ghost'],
});
expect(blocks).toEqual([{ type: 'photo-grid', assetIds: ['p6', 'p2', 'p1', 'p3'] }]);
});
it('keeps only images and videos', () => {
const mixed = [...assets(2), { id: 'x', type: 'OTHER' }, { id: 'v', type: 'VIDEO' }];
const { blocks } = expandAlbumBlocks([albumBlock()], () => mixed);
expect(blocks).toEqual([{ type: 'photo-grid', assetIds: ['p1', 'p2', 'v'] }]);
});
it('orderAlbumAssets pins first occurrences only', () => {
expect(orderAlbumAssets(assets(3), ['p3', 'p3', 'p1']).map((a) => a.id)).toEqual([
'p3',
'p1',
'p2',
]);
});
});

View File

@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { parseJournalMarkdown, serializeJournalMarkdown, calculateReadingTime } from '../journal';
import type { JournalBlock } from '../journal';
const roundTrip = (blocks: JournalBlock[]) =>
parseJournalMarkdown(
serializeJournalMarkdown({ frontmatter: {}, blocks, referencedAssetIds: [] }),
);
describe('facts block', () => {
it('parses a ::facts chunk into label/value items', () => {
const parsed = parseJournalMarkdown('::facts\nDistance: 21 km\nElevation: 1,240 m');
expect(parsed.blocks).toEqual([
{
type: 'facts',
items: [
{ label: 'Distance', value: '21 km' },
{ label: 'Elevation', value: '1,240 m' },
],
},
]);
});
it('splits at the first colon so a time keeps its value', () => {
const parsed = parseJournalMarkdown('::facts\nStart: 08:30');
expect(parsed.blocks[0]).toMatchObject({
type: 'facts',
items: [{ label: 'Start', value: '08:30' }],
});
});
it('ignores lines without a colon or with an empty side', () => {
const parsed = parseJournalMarkdown('::facts\njust words\n: no label\nNo value:\nOk: yes');
expect(parsed.blocks[0]).toEqual({ type: 'facts', items: [{ label: 'Ok', value: 'yes' }] });
});
it('renders inline markdown in values and escapes the rest', () => {
const parsed = parseJournalMarkdown(
'::facts\nGuide: [Anna](https://example.com)\nNote: <b>x</b>',
);
expect(parsed.blocks[0]).toMatchObject({
items: [
{
label: 'Guide',
value: '<a href="https://example.com" target="_blank" rel="noopener noreferrer">Anna</a>',
},
{ label: 'Note', value: '&lt;b&gt;x&lt;/b&gt;' },
],
});
});
it('round-trips, dropping rows the editor left empty', () => {
const block: JournalBlock = {
type: 'facts',
items: [
{ label: 'Distance', value: '21 km' },
{ label: '', value: '' },
{ label: 'Weather', value: 'clear, **cold**' },
],
};
const parsed = roundTrip([block]);
expect(parsed.blocks).toEqual([
{
type: 'facts',
items: [
{ label: 'Distance', value: '21 km' },
{ label: 'Weather', value: 'clear, <strong>cold</strong>' },
],
},
]);
// And the serialized form is stable from here on.
const once = serializeJournalMarkdown(parsed);
expect(serializeJournalMarkdown(parseJournalMarkdown(once))).toBe(once);
expect(once).toBe('::facts\nDistance: 21 km\nWeather: clear, **cold**');
});
it('is its own block between paragraphs', () => {
const parsed = parseJournalMarkdown('Intro.\n\n::facts\nA: 1\n\nOutro.');
expect(parsed.blocks.map((b) => b.type)).toEqual(['paragraph', 'facts', 'paragraph']);
});
it('does not count the directive line as reading', () => {
const { words } = calculateReadingTime('::facts\nDistance: 21 km');
expect(words).toBe(3);
});
});

View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import {
parseJournalMarkdown,
serializeJournalMarkdown,
collectAssetIds,
mapBlockAssetIds,
} from '../journal';
import type { JournalBlock } from '../journal';
const roundTrip = (blocks: JournalBlock[]) =>
parseJournalMarkdown(
serializeJournalMarkdown({ frontmatter: {}, blocks, referencedAssetIds: [] }),
);
describe('photo grid block', () => {
it('parses three or more ids as a grid and keeps them all', () => {
const parsed = parseJournalMarkdown('![a1, b2, c3](Three)');
expect(parsed.blocks).toEqual([
{ type: 'photo-grid', assetIds: ['a1', 'b2', 'c3'], caption: 'Three' },
]);
expect(parsed.referencedAssetIds).toEqual(['a1', 'b2', 'c3']);
});
it('no longer drops the third id on a save (pair used to keep two)', () => {
const once = serializeJournalMarkdown(parseJournalMarkdown('![a1, b2, c3]()'));
expect(once).toBe('![a1, b2, c3]()');
});
it('keeps two ids a pair', () => {
const parsed = parseJournalMarkdown('![a1, b2](Two)');
expect(parsed.blocks[0]).toMatchObject({ type: 'photo-pair', assetIds: ['a1', 'b2'] });
});
it('round-trips five ids with a markdown caption', () => {
const block: JournalBlock = {
type: 'photo-grid',
assetIds: ['a', 'b', 'c', 'd', 'e'],
caption: 'A <strong>bold</strong> caption',
};
expect(roundTrip([block]).blocks).toEqual([block]);
});
it('round-trips unfilled placeholders without referencing them', () => {
const block: JournalBlock = { type: 'photo-grid', assetIds: ['', '', ''], caption: undefined };
const parsed = roundTrip([block]);
expect(parsed.blocks).toEqual([block]);
expect(parsed.referencedAssetIds).toEqual([]);
});
it('references only the filled ids of a half-filled grid, once each', () => {
const blocks: JournalBlock[] = [
{ type: 'photo-grid', assetIds: ['a', '', 'b', 'a'], caption: undefined },
];
expect(collectAssetIds(blocks)).toEqual(['a', 'b']);
expect(roundTrip(blocks).referencedAssetIds).toEqual(['a', 'b']);
});
it('maps every id of a grid through the token function', () => {
const block: JournalBlock = { type: 'photo-grid', assetIds: ['a', 'b', 'c'] };
expect(mapBlockAssetIds(block, (id) => `t:${id}`)).toEqual({
type: 'photo-grid',
assetIds: ['t:a', 't:b', 't:c'],
});
});
it('leaves blocks without ids untouched when mapping', () => {
const block: JournalBlock = { type: 'heading', level: 2, text: 'Hi' };
expect(mapBlockAssetIds(block, () => 'x')).toBe(block);
});
});

View File

@@ -0,0 +1,251 @@
import { describe, it, expect } from 'vitest';
import {
parseJournalMarkdown,
serializeJournalMarkdown,
collectAssetIds,
mapBlockAssetIds,
} from '../journal';
import type { JournalBlock } from '../journal';
import { pinsForEntry } from '../journalMap';
import type { LocationPrecision } from '../mapPrecision';
const mapBlock = (md: string) => parseJournalMarkdown(md).blocks[0];
describe('map block syntax', () => {
it('parses ::map with and without a caption, empty by default', () => {
expect(mapBlock('::map')).toEqual({ type: 'map', caption: undefined, line: true, items: [] });
expect(mapBlock('::map Busan → Seoul')).toMatchObject({ caption: 'Busan → Seoul' });
});
it('does not mistake ::mapping for a map', () => {
expect(mapBlock('::mapping things').type).toBe('paragraph');
});
it('reads every line form, in order', () => {
const block = mapBlock(
[
'::map The trip',
'Busan harbour: 35.098, 129.036',
'photo: aaa, bbb',
'photos: all',
'37.566, 126.978',
'Photo: ccc',
'line: off',
].join('\n'),
);
expect(block).toEqual({
type: 'map',
caption: 'The trip',
line: false,
items: [
{ kind: 'point', label: 'Busan harbour', lat: 35.098, lng: 129.036 },
{ kind: 'photo', assetId: 'aaa' },
{ kind: 'photo', assetId: 'bbb' },
{ kind: 'all-photos' },
{ kind: 'point', lat: 37.566, lng: 126.978 },
{ kind: 'photo', assetId: 'ccc' },
],
});
});
it('ignores malformed and out-of-range coordinates and unknown lines', () => {
const block = mapBlock(
[
'::map',
'Nowhere: 95, 10',
'Also: 10, 200',
'Text without numbers',
'1.5',
'Ok: -33.9, 151.2',
].join('\n'),
);
expect(block).toMatchObject({
items: [{ kind: 'point', label: 'Ok', lat: -33.9, lng: 151.2 }],
});
});
it('round-trips byte-stable and never writes pins', () => {
const block: JournalBlock = {
type: 'map',
caption: 'A <strong>trip</strong>',
line: false,
items: [
{ kind: 'point', label: 'Start', lat: 52.51, lng: 13.47 },
{ kind: 'photo', assetId: 'abc' },
{ kind: 'all-photos' },
{ kind: 'point', lat: 52.4, lng: 13.04 },
],
pins: [{ lat: 1, lng: 2, label: 'never' }],
};
const md = serializeJournalMarkdown({
frontmatter: {},
blocks: [block],
referencedAssetIds: [],
});
expect(md).toBe(
[
'::map A **trip**',
'Start: 52.51, 13.47',
'photo: abc',
'photos: all',
'52.4, 13.04',
'line: off',
].join('\n'),
);
const reparsed = parseJournalMarkdown(md);
const { pins: _pins, ...withoutPins } = block;
expect(reparsed.blocks).toEqual([{ ...withoutPins, caption: 'A <strong>trip</strong>' }]);
expect(serializeJournalMarkdown(reparsed)).toBe(md);
});
it('drops invalid points and empty photo placeholders on save', () => {
const block: JournalBlock = {
type: 'map',
line: true,
items: [
{ kind: 'point', lat: Number.NaN, lng: Number.NaN },
{ kind: 'photo', assetId: '' },
{ kind: 'point', label: 'Kept', lat: 1, lng: 2 },
],
};
expect(
serializeJournalMarkdown({ frontmatter: {}, blocks: [block], referencedAssetIds: [] }),
).toBe('::map\nKept: 1, 2');
});
it('collects and maps the ids of photo pins', () => {
const block: JournalBlock = {
type: 'map',
line: true,
items: [
{ kind: 'photo', assetId: 'abc' },
{ kind: 'all-photos' },
{ kind: 'photo', assetId: '' },
],
};
expect(collectAssetIds([block])).toEqual(['abc']);
expect(mapBlockAssetIds(block, (id) => `t:${id}`)).toMatchObject({
items: [
{ kind: 'photo', assetId: 't:abc' },
{ kind: 'all-photos' },
{ kind: 'photo', assetId: 't:' },
],
});
});
});
const asset = (
id: string,
lat: number | null,
lng: number | null,
city = 'Busan',
country = 'South Korea',
) => ({
id,
exifInfo: {
latitude: lat,
longitude: lng,
city,
country,
} as unknown as NonNullable<import('../immich').ImmichAsset['exifInfo']>,
});
describe('pinsForEntry', () => {
const exact = (): LocationPrecision => 'exact';
it('publishes typed points as typed, no snapping, no label invented', () => {
const pins = pinsForEntry(
[
{ kind: 'point', label: 'Harbour', lat: 35.098123, lng: 129.036456 },
{ kind: 'point', lat: 37.5, lng: 127 },
],
[],
[],
exact,
);
expect(pins).toEqual([
{ lat: 35.098123, lng: 129.036456, label: 'Harbour' },
{ lat: 37.5, lng: 127 },
]);
});
it('places a photo pin on the 1 km grid with the city label', () => {
const pins = pinsForEntry(
[{ kind: 'photo', assetId: 'a' }],
[asset('a', 52.510044, 13.467244, 'Berlin', 'Germany')],
[],
exact,
);
expect(pins).toEqual([{ lat: 52.51, lng: 13.47, label: 'Berlin, Germany' }]);
});
it('expands photos: all in entry order, skipping ids listed explicitly', () => {
const assets = [asset('a', 1, 1), asset('b', 2, 2), asset('c', 3, 3)];
const pins = pinsForEntry(
[{ kind: 'photo', assetId: 'c' }, { kind: 'all-photos' }],
assets,
['a', 'b', 'c'],
exact,
);
expect(pins.map((p) => [p.lat, p.lng])).toEqual([
[3, 3],
[1, 1],
[2, 2],
]);
});
it('keeps the drawing order across mixed items', () => {
const pins = pinsForEntry(
[
{ kind: 'point', label: 'Start', lat: 0.5, lng: 0.5 },
{ kind: 'photo', assetId: 'a' },
{ kind: 'point', label: 'End', lat: 9, lng: 9 },
],
[asset('a', 1, 1)],
['a'],
exact,
);
expect(pins.map((p) => p.label)).toEqual(['Start', 'Busan, South Korea', 'End']);
});
it('applies precision to photo pins only: city snaps, country and hidden vanish', () => {
const levels: Record<string, LocationPrecision> = { a: 'city', b: 'country', c: 'hidden' };
const pins = pinsForEntry(
[{ kind: 'all-photos' }, { kind: 'point', lat: 52.5163, lng: 13.3777 }],
[asset('a', 52.5163, 13.3777, 'Berlin', 'Germany'), asset('b', 2, 2), asset('c', 3, 3)],
['a', 'b', 'c'],
(id) => levels[id],
);
expect(pins).toEqual([
{ lat: 52.5, lng: 13.4, label: 'Berlin, Germany' },
{ lat: 52.5163, lng: 13.3777 },
]);
});
it('skips photos without coordinates but keeps the equator (#635)', () => {
const pins = pinsForEntry(
[{ kind: 'all-photos' }],
[asset('a', null, null), asset('b', 0, 0, '', '')],
['a', 'b'],
exact,
);
expect(pins).toEqual([{ lat: 0, lng: 0 }]);
});
it('merges items at one position into one pin', () => {
const pins = pinsForEntry(
[
{ kind: 'point', lat: 52.51, lng: 13.47 },
{ kind: 'photo', assetId: 'a' },
],
[asset('a', 52.510044, 13.467244)],
[],
exact,
);
expect(pins).toHaveLength(1);
});
it('ignores photo items the page did not fetch', () => {
expect(pinsForEntry([{ kind: 'photo', assetId: 'missing' }], [], [], exact)).toEqual([]);
});
});

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { parseJournalMarkdown, serializeJournalMarkdown } from '../journal'; import { parseJournalMarkdown, serializeJournalMarkdown } from '../journal';
import type { JournalBlock } from '../journal';
/** /**
* serializeJournalMarkdown must be the exact inverse of the inline renderer * serializeJournalMarkdown must be the exact inverse of the inline renderer
@@ -89,4 +90,43 @@ describe('journal round trip (#628)', () => {
expect(once).toContain('[Immich](https://immich.app)'); expect(once).toContain('[Immich](https://immich.app)');
expect(once).toContain("Anna's photos"); expect(once).toContain("Anna's photos");
}); });
it('round-trips an unfilled photo placeholder as a photo block, not text (#template placeholders)', () => {
for (const layout of ['contained', 'wide', 'fullbleed'] as const) {
const block: JournalBlock = { type: 'photo', assetId: '', caption: 'Pick a photo', layout };
const serialized = serializeJournalMarkdown({
frontmatter: {},
blocks: [block],
referencedAssetIds: [],
});
const parsed = parseJournalMarkdown(serialized);
expect(parsed.blocks).toEqual([block]);
expect(parsed.referencedAssetIds).toEqual([]);
}
});
it('round-trips an unfilled photo-pair placeholder without referencing empty ids', () => {
const block: JournalBlock = { type: 'photo-pair', assetIds: ['', ''], caption: 'Two to pick' };
const serialized = serializeJournalMarkdown({
frontmatter: {},
blocks: [block],
referencedAssetIds: [],
});
const parsed = parseJournalMarkdown(serialized);
expect(parsed.blocks).toEqual([block]);
expect(parsed.referencedAssetIds).toEqual([]);
});
it('keeps a filled id in a half-filled pair as the only reference', () => {
const block: JournalBlock = {
type: 'photo-pair',
assetIds: ['abc123', ''],
caption: undefined,
};
const parsed = parseJournalMarkdown(
serializeJournalMarkdown({ frontmatter: {}, blocks: [block], referencedAssetIds: [] }),
);
expect(parsed.blocks).toEqual([block]);
expect(parsed.referencedAssetIds).toEqual(['abc123']);
});
}); });

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { parseJournalMarkdown, serializeJournalMarkdown } from '../journal';
import { JOURNAL_TEMPLATES } from '../journalTemplates';
describe('journal templates', () => {
it('has at least one template and unique ids', () => {
expect(JOURNAL_TEMPLATES.length).toBeGreaterThan(0);
const ids = JOURNAL_TEMPLATES.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
});
for (const template of JOURNAL_TEMPLATES) {
it(`round-trips "${template.id}" without losing or reshaping blocks`, () => {
const serialized = serializeJournalMarkdown({
frontmatter: {},
blocks: template.blocks,
referencedAssetIds: [],
});
const parsed = parseJournalMarkdown(serialized);
expect(parsed.blocks).toEqual(template.blocks);
// Placeholders must not surface as assets to probe or render.
expect(parsed.referencedAssetIds).toEqual([]);
});
it(`"${template.id}" never flags a placeholder as a legacy asset reference`, () => {
for (const block of template.blocks) {
if (block.type === 'photo') expect(block.assetId).toBe('');
if (block.type === 'photo-pair' || block.type === 'photo-grid') {
for (const id of block.assetIds) expect(id).toBe('');
}
// A facts row with an empty side is dropped on save; a template must
// not ship rows that vanish on first load.
if (block.type === 'facts') {
for (const item of block.items) {
expect(item.label).not.toBe('');
expect(item.value).not.toBe('');
}
}
}
});
}
});

View File

@@ -185,14 +185,37 @@ export function isProtected(key: string, type: ProtectedType = 'subpage'): boole
return !!findPassword(key, type); return !!findPassword(key, type);
} }
/**
* Whether the visitor reached us over HTTPS — decides the `Secure` cookie flag.
*
* This used to be `NODE_ENV === 'production'`, which is always true in the
* Docker image. A browser silently drops a `Secure` cookie set over plain HTTP,
* so a deployment at `http://host:7211` accepted the password and then answered
* every following request with 401 (#664).
*
* `X-Forwarded-Proto` is trusted without `TRUSTED_PROXY_HOPS`: a visitor who
* forges it only changes the flag on their own cookie, in their own browser.
* A proxy that omits it costs an HTTPS site the flag, which the HSTS header on
* every page makes close to moot — the browser never asks over HTTP again.
*/
export function isHttpsRequest(request: Request): boolean {
const forwarded = request.headers.get('x-forwarded-proto');
if (forwarded) return forwarded.split(',')[0].trim().toLowerCase() === 'https';
return new URL(request.url).protocol === 'https:';
}
/** /**
* Validate a password attempt and return a Set-Cookie header value on success. * Validate a password attempt and return a Set-Cookie header value on success.
* Returns null if the password is wrong. * Returns null if the password is wrong.
*
* `secure` comes from `isHttpsRequest()`; it defaults to on so that a caller
* which forgets it errs towards the stricter cookie.
*/ */
export async function authenticate( export async function authenticate(
key: string, key: string,
password: string, password: string,
type: ProtectedType = 'subpage', type: ProtectedType = 'subpage',
secure = true,
): Promise<string | null> { ): Promise<string | null> {
const storedPassword = findPassword(key, type); const storedPassword = findPassword(key, type);
if (!storedPassword) return null; if (!storedPassword) return null;
@@ -237,9 +260,9 @@ export async function authenticate(
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60; const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
const token = authToken(tokenKey(key, type), storedPassword, Date.now() + maxAge * 1000); const token = authToken(tokenKey(key, type), storedPassword, Date.now() + maxAge * 1000);
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : ''; const secureFlag = secure ? '; Secure' : '';
return `${cookieName(key, type)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict${secure}`; return `${cookieName(key, type)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict${secureFlag}`;
} }
/** /**

View File

@@ -7,9 +7,13 @@ export {
type JournalFrontmatter as EssayFrontmatter, type JournalFrontmatter as EssayFrontmatter,
type JournalBlock as EssayBlock, type JournalBlock as EssayBlock,
type ParsedJournal as ParsedEssay, type ParsedJournal as ParsedEssay,
type MapItem,
isValidCoordinate,
sanitizeHtml, sanitizeHtml,
renderInlineMarkdown, renderInlineMarkdown,
parseFrontmatter, parseFrontmatter,
parseJournalMarkdown as parseEssayMarkdown, parseJournalMarkdown as parseEssayMarkdown,
serializeJournalMarkdown as serializeEssayMarkdown, serializeJournalMarkdown as serializeEssayMarkdown,
collectAssetIds,
mapBlockAssetIds,
} from './journal'; } from './journal';

View File

@@ -27,6 +27,8 @@ export const de: Dictionary = {
backTo: (label: string) => `Zurück zu ${label}`, backTo: (label: string) => `Zurück zu ${label}`,
backToGallery: 'Zurück zur Galerie', backToGallery: 'Zurück zur Galerie',
backToJournal: 'Zurück zum Journal', backToJournal: 'Zurück zum Journal',
email: 'E-Mail',
website: 'Website',
home: 'Start', home: 'Start',
photos: (n: number) => plural(n, 'Foto', 'Fotos'), photos: (n: number) => plural(n, 'Foto', 'Fotos'),
albums: (n: number) => plural(n, 'Album', 'Alben'), albums: (n: number) => plural(n, 'Album', 'Alben'),
@@ -43,6 +45,7 @@ export const de: Dictionary = {
entryNavAria: 'Journal-Navigation', entryNavAria: 'Journal-Navigation',
loadingGallery: 'Galerie wird geladen', loadingGallery: 'Galerie wird geladen',
loadingPhotos: 'Fotos werden geladen', loadingPhotos: 'Fotos werden geladen',
downloadAlbum: 'Album herunterladen',
}, },
home: { home: {
@@ -61,6 +64,15 @@ export const de: Dictionary = {
reference: (digest: string) => `Referenz: ${digest}`, reference: (digest: string) => `Referenz: ${digest}`,
}, },
download: {
unavailableTitle: 'Download nicht möglich',
notAvailable: 'Dieser Download ist nicht verfügbar.',
rateLimited: 'Zu viele Download-Anfragen. Bitte einen Moment warten und erneut versuchen.',
immichUnavailable:
'Die Fotobibliothek ist gerade nicht erreichbar. Bitte gleich noch einmal versuchen.',
back: 'Zurück zur Galerie',
},
theme: { theme: {
switchToLight: 'Zum hellen Modus wechseln', switchToLight: 'Zum hellen Modus wechseln',
switchToDark: 'Zum dunklen Modus wechseln', switchToDark: 'Zum dunklen Modus wechseln',
@@ -118,6 +130,7 @@ export const de: Dictionary = {
lightbox: { lightbox: {
viewer: 'Bildansicht', viewer: 'Bildansicht',
openPhoto: (n: number) => `Foto ${n} ansehen`,
close: 'Schließen', close: 'Schließen',
closeTitle: 'Schließen (Esc)', closeTitle: 'Schließen (Esc)',
previous: 'Vorheriges Foto', previous: 'Vorheriges Foto',
@@ -178,7 +191,10 @@ export const de: Dictionary = {
linkCopied: 'Link kopiert!', linkCopied: 'Link kopiert!',
copyList: 'Textliste kopieren (#1, #2...)', copyList: 'Textliste kopieren (#1, #2...)',
listCopied: 'Liste kopiert!', listCopied: 'Liste kopiert!',
copyManualLink: 'Diesen Link kopieren',
copyManualList: 'Diese Liste kopieren',
sendEmail: '✉️ E-Mail an den Fotografen', sendEmail: '✉️ E-Mail an den Fotografen',
downloadSelected: 'Auswahl herunterladen (.zip)',
clearSelection: 'Auswahl löschen', clearSelection: 'Auswahl löschen',
confirmClear: 'Die gesamte Auswahl löschen?', confirmClear: 'Die gesamte Auswahl löschen?',
listEmpty: (albumName: string) => `${albumName}: Keine Fotos ausgewählt.`, listEmpty: (albumName: string) => `${albumName}: Keine Fotos ausgewählt.`,

View File

@@ -27,6 +27,9 @@ export const en = {
backTo: (label: string) => `Back to ${label}`, backTo: (label: string) => `Back to ${label}`,
backToGallery: 'Back to Gallery', backToGallery: 'Back to Gallery',
backToJournal: 'Back to Journal', backToJournal: 'Back to Journal',
/** Footer contact links, read out by screen readers. */
email: 'Email',
website: 'Website',
home: 'Home', home: 'Home',
photos: (n: number) => plural(n, 'photo', 'photos'), photos: (n: number) => plural(n, 'photo', 'photos'),
albums: (n: number) => plural(n, 'album', 'albums'), albums: (n: number) => plural(n, 'album', 'albums'),
@@ -43,6 +46,7 @@ export const en = {
entryNavAria: 'Journal entry navigation', entryNavAria: 'Journal entry navigation',
loadingGallery: 'Loading gallery', loadingGallery: 'Loading gallery',
loadingPhotos: 'Loading photos', loadingPhotos: 'Loading photos',
downloadAlbum: 'Download album',
}, },
home: { home: {
@@ -61,6 +65,15 @@ export const en = {
reference: (digest: string) => `Reference: ${digest}`, reference: (digest: string) => `Reference: ${digest}`,
}, },
/** The page the ZIP route answers a browser with when it refuses. */
download: {
unavailableTitle: 'Download unavailable',
notAvailable: 'This download is not available.',
rateLimited: 'Too many download requests. Please wait a moment and try again.',
immichUnavailable: 'The photo library is unavailable right now. Please try again shortly.',
back: 'Back to the gallery',
},
theme: { theme: {
switchToLight: 'Switch to light mode', switchToLight: 'Switch to light mode',
switchToDark: 'Switch to dark mode', switchToDark: 'Switch to dark mode',
@@ -118,6 +131,8 @@ export const en = {
lightbox: { lightbox: {
viewer: 'Image viewer', viewer: 'Image viewer',
/** A grid tile, which opens the viewer on that photo. */
openPhoto: (n: number) => `View photo ${n}`,
close: 'Close', close: 'Close',
closeTitle: 'Close (Esc)', closeTitle: 'Close (Esc)',
previous: 'Previous photo', previous: 'Previous photo',
@@ -178,7 +193,11 @@ export const en = {
linkCopied: 'Link Copied!', linkCopied: 'Link Copied!',
copyList: 'Copy Text List (#1, #2...)', copyList: 'Copy Text List (#1, #2...)',
listCopied: 'List Copied!', listCopied: 'List Copied!',
/** Shown when the browser offers no clipboard (plain http) — copy by hand. */
copyManualLink: 'Copy this link',
copyManualList: 'Copy this list',
sendEmail: '✉️ Send Email to Photographer', sendEmail: '✉️ Send Email to Photographer',
downloadSelected: 'Download selected (.zip)',
clearSelection: 'Clear Selection', clearSelection: 'Clear Selection',
confirmClear: 'Clear all selected favorites?', confirmClear: 'Clear all selected favorites?',
listEmpty: (albumName: string) => `${albumName}: No photos selected.`, listEmpty: (albumName: string) => `${albumName}: No photos selected.`,

View File

@@ -19,7 +19,53 @@ export type JournalBlock =
| { type: 'paragraph'; html: string } | { type: 'paragraph'; html: string }
| { type: 'quote'; text: string; author?: string } | { type: 'quote'; text: string; author?: string }
| { type: 'photo'; assetId: string; caption?: string; layout: 'fullbleed' | 'wide' | 'contained' } | { type: 'photo'; assetId: string; caption?: string; layout: 'fullbleed' | 'wide' | 'contained' }
| { type: 'photo-pair'; assetIds: [string, string]; caption?: string }; | { type: 'photo-pair'; assetIds: [string, string]; caption?: string }
| { type: 'photo-grid'; assetIds: string[]; caption?: string }
| { type: 'facts'; items: Array<{ label: string; value: string }> }
| { type: 'map'; caption?: string; line: boolean; items: MapItem[]; pins?: MapPin[] }
| {
/**
* "x photos from an album". Expanded on the server into photo blocks
* (lib/journalAlbum.ts) before the page renders, so the client never
* sees this type; the studio expands it through the admin assets route.
*/
type: 'album';
albumId: string;
count?: number;
skip?: number;
layout: AlbumBlockLayout;
caption?: string;
};
export type AlbumBlockLayout = 'grid' | 'pairs' | 'wide';
/**
* What an author puts on a journal map, in the order the line is drawn.
* A typed point is published as typed. A photo item is placed by its EXIF
* position under the album's `location:` precision; `all-photos` expands to
* every geotagged photo of the entry not already listed.
*/
export type MapItem =
| { kind: 'point'; label?: string; lat: number; lng: number }
| { kind: 'photo'; assetId: string }
| { kind: 'all-photos' };
export function isValidCoordinate(lat: number, lng: number): boolean {
return (
Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180
);
}
/**
* A position on a journal map. Never authored and never serialized: the
* server derives pins from the entry's own geotagged photos at render time
* (lib/journalMap.ts), already quantised to the album's `location:` setting.
*/
export interface MapPin {
lat: number;
lng: number;
label?: string;
}
export interface ParsedJournal { export interface ParsedJournal {
frontmatter: JournalFrontmatter; frontmatter: JournalFrontmatter;
@@ -27,6 +73,51 @@ export interface ParsedJournal {
referencedAssetIds: string[]; referencedAssetIds: string[];
} }
/**
* Every asset id a block refers to, in block order, without duplicates.
* An empty id is an unfilled placeholder (the block editor's "+ Pick" tile),
* not a reference — it is skipped so nothing probes or renders it.
*/
export function collectAssetIds(blocks: readonly JournalBlock[]): string[] {
const ids = new Set<string>();
for (const block of blocks) {
if (block.type === 'photo') {
if (block.assetId) ids.add(block.assetId);
} else if (block.type === 'photo-pair' || block.type === 'photo-grid') {
for (const id of block.assetIds) if (id) ids.add(id);
} else if (block.type === 'map') {
for (const item of block.items)
if (item.kind === 'photo' && item.assetId) ids.add(item.assetId);
}
}
return Array.from(ids);
}
/**
* The same block with every asset id passed through `fn` — the one place that
* knows which block types carry ids, so the page that swaps raw UUIDs for
* tokens does not grow a branch per type.
*/
export function mapBlockAssetIds(block: JournalBlock, fn: (id: string) => string): JournalBlock {
switch (block.type) {
case 'photo':
return { ...block, assetId: fn(block.assetId) };
case 'photo-pair':
return { ...block, assetIds: [fn(block.assetIds[0]), fn(block.assetIds[1])] };
case 'photo-grid':
return { ...block, assetIds: block.assetIds.map(fn) };
case 'map':
return {
...block,
items: block.items.map((item) =>
item.kind === 'photo' ? { ...item, assetId: fn(item.assetId) } : item,
),
};
default:
return block;
}
}
export interface JournalEntrySummary { export interface JournalEntrySummary {
slug: string; slug: string;
filename: string; filename: string;
@@ -289,11 +380,6 @@ export function parseFrontmatter(content: string): {
export function parseJournalMarkdown(rawContent: string): ParsedJournal { export function parseJournalMarkdown(rawContent: string): ParsedJournal {
const { frontmatter, body } = parseFrontmatter(rawContent); const { frontmatter, body } = parseFrontmatter(rawContent);
const blocks: JournalBlock[] = []; const blocks: JournalBlock[] = [];
const referencedAssetIds = new Set<string>();
if (frontmatter.coverAssetId) {
referencedAssetIds.add(frontmatter.coverAssetId);
}
// Split body into paragraph chunks separated by blank lines // Split body into paragraph chunks separated by blank lines
const chunks = body const chunks = body
@@ -302,6 +388,106 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
.filter(Boolean); .filter(Boolean);
for (const chunk of chunks) { for (const chunk of chunks) {
// 0a. Map: `::map Caption`, then one line per pin in drawing order —
// `Label: lat, lng` or `lat, lng` for a typed point, `photo: <id>` for
// a photo placed by its GPS, `photos: all` for every geotagged photo
// of the entry, `line: off` to drop the connecting line. Pins are
// never in the file — see MapPin. Malformed lines are ignored.
const [mapFirst, ...mapRest] = chunk.split('\n');
const mapMatch = mapFirst.match(/^::map(?:[ \t]+(\S.*))?[ \t]*$/);
if (mapMatch) {
const caption = mapMatch[1]?.trim();
const items: MapItem[] = [];
let line = true;
const coords = (s: string) => {
const m = s.match(/^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/);
if (!m) return null;
const lat = Number(m[1]);
const lng = Number(m[2]);
return isValidCoordinate(lat, lng) ? { lat, lng } : null;
};
for (const raw of mapRest) {
const text = raw.trim();
if (!text) continue;
const colon = text.indexOf(':');
const key = colon === -1 ? '' : text.slice(0, colon).trim();
const value = colon === -1 ? text : text.slice(colon + 1).trim();
const lowerKey = key.toLowerCase();
if (lowerKey === 'line') {
line = !['off', 'false', 'no', '0'].includes(value.toLowerCase());
} else if (lowerKey === 'photos') {
if (value.toLowerCase() === 'all') items.push({ kind: 'all-photos' });
} else if (lowerKey === 'photo') {
for (const id of value.split(',')) {
const assetId = id.trim();
if (assetId) items.push({ kind: 'photo', assetId });
}
} else {
const pos = coords(value);
if (pos)
items.push(key ? { kind: 'point', label: key, ...pos } : { kind: 'point', ...pos });
}
}
blocks.push({
type: 'map',
caption: caption ? renderInlineMarkdown(caption) : undefined,
line,
items,
});
continue;
}
// 0b. Facts: a `::facts` line, then `Label: Value` lines in the same chunk.
// `::` because `![facts]` would read as a legacy photo reference and `#`
// and `>` are taken; no paragraph starts that way. The first colon
// splits, so `Time: 08:30` keeps its value. Lines without one, or with
// an empty side, are ignored.
if (chunk.startsWith('::facts')) {
const items = chunk
.split('\n')
.slice(1)
.flatMap((line) => {
const colon = line.indexOf(':');
if (colon === -1) return [];
const label = line.slice(0, colon).trim();
const value = line.slice(colon + 1).trim();
return label && value ? [{ label, value: renderInlineMarkdown(value) }] : [];
});
blocks.push({ type: 'facts', items });
continue;
}
// 0c. Album: `::album <id>` with `count:`, `skip:`, `layout:` and
// `caption:` lines. An empty id is the studio's unfilled placeholder.
const [albumFirst, ...albumRest] = chunk.split('\n');
const albumMatch = albumFirst.match(/^::album(?:[ \t]+(\S+))?[ \t]*$/);
if (albumMatch) {
let count: number | undefined;
let skip: number | undefined;
let layout: AlbumBlockLayout = 'grid';
let caption: string | undefined;
for (const raw of albumRest) {
const colon = raw.indexOf(':');
if (colon === -1) continue;
const key = raw.slice(0, colon).trim().toLowerCase();
const value = raw.slice(colon + 1).trim();
if (key === 'count' && /^\d+$/.test(value)) count = Number(value);
else if (key === 'skip' && /^\d+$/.test(value)) skip = Number(value);
else if (key === 'layout' && (value === 'grid' || value === 'pairs' || value === 'wide'))
layout = value;
else if (key === 'caption' && value) caption = renderInlineMarkdown(value);
}
blocks.push({
type: 'album',
albumId: albumMatch[1] ?? '',
...(count !== undefined ? { count } : {}),
...(skip ? { skip } : {}),
layout,
...(caption ? { caption } : {}),
});
continue;
}
// 1. Headings (# H1, ## H2, ### H3) // 1. Headings (# H1, ## H2, ### H3)
// `[ \t]+` rather than `\s+`: next to `(.+)` the two overlap, and the // `[ \t]+` rather than `\s+`: next to `(.+)` the two overlap, and the
// engine has to try every split of the whitespace run before failing. // engine has to try every split of the whitespace run before failing.
@@ -333,23 +519,30 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
continue; continue;
} }
// 3. Image syntax: ![assetId:layout](Caption) or ![assetId1, assetId2](Caption) // 3. Image syntax: ![assetId:layout](Caption), ![id1, id2](Caption) for a
const imgMatch = chunk.match(/^!\[([^\]]+)\]\(([^)]*)\)$/); // pair, ![id1, id2, id3, …](Caption) for a grid
//
// The bracket group allows zero characters (`*`, not `+`) so a template's
// unfilled placeholder — `assetId: ''`, serialized as `![](Caption)` or
// `![:wide](Caption)` — still parses back into a `photo` block instead of
// silently degrading into a text paragraph on the next load.
const imgMatch = chunk.match(/^!\[([^\]]*)\]\(([^)]*)\)$/);
if (imgMatch) { if (imgMatch) {
const rawTarget = imgMatch[1].trim(); const rawTarget = imgMatch[1].trim();
const caption = imgMatch[2].trim() ? renderInlineMarkdown(imgMatch[2].trim()) : undefined; const caption = imgMatch[2].trim() ? renderInlineMarkdown(imgMatch[2].trim()) : undefined;
// Side-by-side pair: ![asset1, asset2](Caption) // Two ids are a side-by-side pair, three or more a grid. The pair used
// to take the first two of any count and drop the rest on the next save.
if (rawTarget.includes(',')) { if (rawTarget.includes(',')) {
const parts = rawTarget.split(',').map((s) => s.trim()); const parts = rawTarget.split(',').map((s) => s.trim());
if (parts.length >= 2) { if (parts.length >= 3) {
const id1 = parts[0]; blocks.push({ type: 'photo-grid', assetIds: parts, caption });
const id2 = parts[1]; continue;
referencedAssetIds.add(id1); }
referencedAssetIds.add(id2); if (parts.length === 2) {
blocks.push({ blocks.push({
type: 'photo-pair', type: 'photo-pair',
assetIds: [id1, id2], assetIds: [parts[0], parts[1]],
caption, caption,
}); });
continue; continue;
@@ -368,7 +561,6 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
else if (normLayout === 'wide') layout = 'wide'; else if (normLayout === 'wide') layout = 'wide';
} }
referencedAssetIds.add(assetId);
blocks.push({ blocks.push({
type: 'photo', type: 'photo',
assetId, assetId,
@@ -385,11 +577,15 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
}); });
} }
return { // The cover comes first, as before; collectAssetIds() skips '' placeholders.
frontmatter, const referencedAssetIds = Array.from(
blocks, new Set([
referencedAssetIds: Array.from(referencedAssetIds), ...(frontmatter.coverAssetId ? [frontmatter.coverAssetId] : []),
}; ...collectAssetIds(blocks),
]),
);
return { frontmatter, blocks, referencedAssetIds };
} }
/** Converts a ParsedJournal structure back into clean Markdown syntax */ /** Converts a ParsedJournal structure back into clean Markdown syntax */
@@ -451,6 +647,52 @@ export function serializeJournalMarkdown(journal: ParsedJournal): string {
lines.push(`![${block.assetIds[0]}, ${block.assetIds[1]}](${caption})`); lines.push(`![${block.assetIds[0]}, ${block.assetIds[1]}](${caption})`);
break; break;
} }
case 'photo-grid': {
const caption = block.caption
? inlineHtmlToMarkdown(block.caption.replace(/[\r\n]+/g, ' '))
: '';
lines.push(`![${block.assetIds.join(', ')}](${caption})`);
break;
}
case 'map': {
const caption = block.caption
? inlineHtmlToMarkdown(block.caption.replace(/[\r\n]+/g, ' ')).trim()
: '';
lines.push(caption ? `::map ${caption}` : '::map');
for (const item of block.items) {
if (item.kind === 'all-photos') lines.push('photos: all');
else if (item.kind === 'photo') {
if (item.assetId) lines.push(`photo: ${item.assetId}`);
} else if (isValidCoordinate(item.lat, item.lng)) {
const label = item.label?.replace(/[\r\n]+/g, ' ').trim();
lines.push(label ? `${label}: ${item.lat}, ${item.lng}` : `${item.lat}, ${item.lng}`);
}
}
if (!block.line) lines.push('line: off');
break;
}
case 'album': {
lines.push(block.albumId ? `::album ${block.albumId}` : '::album');
if (block.count !== undefined) lines.push(`count: ${block.count}`);
if (block.skip) lines.push(`skip: ${block.skip}`);
if (block.layout !== 'grid') lines.push(`layout: ${block.layout}`);
if (block.caption) {
const caption = inlineHtmlToMarkdown(block.caption.replace(/[\r\n]+/g, ' ')).trim();
if (caption) lines.push(`caption: ${caption}`);
}
break;
}
case 'facts': {
lines.push('::facts');
for (const item of block.items) {
const label = item.label.trim();
const value = inlineHtmlToMarkdown(item.value)
.replace(/[\r\n]+/g, ' ')
.trim();
if (label && value) lines.push(`${label}: ${value}`);
}
break;
}
} }
lines.push(''); lines.push('');
} }
@@ -460,7 +702,11 @@ export function serializeJournalMarkdown(journal: ParsedJournal): string {
/** Calculate approximate word count and reading time */ /** Calculate approximate word count and reading time */
export function calculateReadingTime(text: string): { words: number; minutes: number } { export function calculateReadingTime(text: string): { words: number; minutes: number } {
const plainText = text.replace(/<[^>]+>/g, ' ').replace(/!\[.*?\]\(.*?\)/g, ' '); const plainText = text
.replace(/<[^>]+>/g, ' ')
.replace(/!\[.*?\]\(.*?\)/g, ' ')
// Directive lines (`::facts`, `::map …`) are structure, not reading.
.replace(/^::\w+.*$/gm, ' ');
const words = plainText.trim().split(/\s+/).filter(Boolean).length; const words = plainText.trim().split(/\s+/).filter(Boolean).length;
const minutes = Math.max(1, Math.round(words / 200)); const minutes = Math.max(1, Math.round(words / 200));
return { words, minutes }; return { words, minutes };

107
lib/journalAlbum.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* Album blocks — "x photos from an album" — expanded into ordinary photo
* blocks. Pure and client-safe: the caller supplies the album's assets (the
* server from `immich.getAlbumAssetsRaw()`, the studio from the admin assets
* route), so the same expansion runs on the page and in the preview.
*/
import type { AlbumBlockLayout, JournalBlock } from './journal';
export interface AlbumAssetRef {
id: string;
type: string;
}
/**
* The gallery's `assetOrder` is a pinned prefix, not a permutation (see
* lib/albumSort.ts): pinned ids first in their order, everything else in the
* order given — which for a raw album fetch is Immich's own.
*/
export function orderAlbumAssets<T extends { id: string }>(
assets: T[],
manualOrder?: string[],
): T[] {
if (!manualOrder?.length) return assets;
const rank = new Map<string, number>();
manualOrder.forEach((id, i) => {
if (!rank.has(id)) rank.set(id, i);
});
const pinned: T[] = [];
const rest: T[] = [];
for (const asset of assets) (rank.has(asset.id) ? pinned : rest).push(asset);
pinned.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
return [...pinned, ...rest];
}
function photoBlocks(ids: string[], layout: AlbumBlockLayout, caption?: string): JournalBlock[] {
if (layout === 'wide') {
return ids.map((assetId, i) => ({
type: 'photo',
assetId,
layout: 'wide',
...(caption && i === ids.length - 1 ? { caption } : {}),
}));
}
if (layout === 'pairs') {
const out: JournalBlock[] = [];
for (let i = 0; i < ids.length; i += 2) {
const last = i + 2 >= ids.length;
if (i + 1 < ids.length) {
out.push({
type: 'photo-pair',
assetIds: [ids[i], ids[i + 1]],
...(caption && last ? { caption } : {}),
});
} else {
out.push({
type: 'photo',
assetId: ids[i],
layout: 'contained',
...(caption ? { caption } : {}),
});
}
}
return out;
}
// grid — a grid needs three; fewer fall back to what the count allows.
if (ids.length >= 3)
return [{ type: 'photo-grid', assetIds: ids, ...(caption ? { caption } : {}) }];
if (ids.length === 2) {
return [{ type: 'photo-pair', assetIds: [ids[0], ids[1]], ...(caption ? { caption } : {}) }];
}
return [{ type: 'photo', assetId: ids[0], layout: 'contained', ...(caption ? { caption } : {}) }];
}
/**
* Replace every album block with photo blocks. An album the lookup does not
* know (not fetched, unreachable, or the studio still loading it), an empty
* id, or a selection that leaves no photos removes the block. Returns the
* ids the expansion introduced so the caller can fetch or measure them.
*/
export function expandAlbumBlocks(
blocks: readonly JournalBlock[],
lookup: (albumId: string) => readonly AlbumAssetRef[] | undefined,
manualOrders?: Record<string, string[]>,
): { blocks: JournalBlock[]; albumAssetIds: string[] } {
const albumAssetIds: string[] = [];
const out = blocks.flatMap((block): JournalBlock[] => {
if (block.type !== 'album') return [block];
if (!block.albumId) return [];
const assets = lookup(block.albumId);
if (!assets) return [];
const ordered = orderAlbumAssets(
assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO'),
manualOrders?.[block.albumId],
);
const skip = block.skip ?? 0;
const ids = ordered
.slice(skip, block.count !== undefined ? skip + block.count : undefined)
.map((a) => a.id);
if (ids.length === 0) return [];
albumAssetIds.push(...ids);
return photoBlocks(ids, block.layout, block.caption);
});
return { blocks: out, albumAssetIds: Array.from(new Set(albumAssetIds)) };
}

117
lib/journalMap.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* Pins for a journal map block.
*
* The block's items say what goes on the map, in order. A typed point is the
* author's content and is published as typed. A photo item is derived from
* EXIF the author may not have thought about, so it is placed under the same
* per-photo precision the EXIF route applies (album `location:`, #469): the
* map page is the only other public GPS surface and publishes positions
* quantised per album, and a map inside a story must not become a sloppier
* one. Everything here runs on the server, from photos the page has already
* fetched; only quantised positions and allowed place names reach the client.
*/
import type { ImmichAsset } from './immich';
import type { MapItem, MapPin } from './journal';
import { applyPrecision, placeLabel, snapToGrid, type LocationPrecision } from './mapPrecision';
import { assetLocationPrecision } from './assetLocation';
type GeoAsset = Pick<ImmichAsset, 'id' | 'exifInfo'>;
/**
* `exact` on the map page means the mean of an album's photos in a city —
* never a single photo's coordinates. A journal photo pin is one photo, so
* `exact` here is snapped to a 0.01° grid (~1 km): a hike or a trip stays a
* route, a house does not become a marker. Decided 2026-09-22.
*/
const EXACT_STEP = 0.01;
const EXACT_DECIMALS = 2;
/**
* Pure. `entryPhotoIds` are the photos the story shows, in order — what
* `photos: all` expands to, minus the ids listed as explicit photo items.
* `country` produces no pin: it snaps to a 1° cell, a hundred kilometres,
* and on a story-sized map that is not a place. Two items at one (quantised)
* position become one pin; first appearance wins the order.
*/
export function pinsForEntry(
items: readonly MapItem[],
assets: readonly GeoAsset[],
entryPhotoIds: readonly string[],
precisionOf: (assetId: string) => LocationPrecision,
): MapPin[] {
const byId = new Map(assets.map((a) => [a.id, a]));
const explicitPhotoIds = new Set(
items.flatMap((item) => (item.kind === 'photo' ? [item.assetId] : [])),
);
const seen = new Set<string>();
const pins: MapPin[] = [];
const push = (pin: MapPin) => {
const key = `${pin.lat},${pin.lng}`;
if (seen.has(key)) return;
seen.add(key);
pins.push(pin);
};
const photoPin = (id: string): MapPin | null => {
const exif = byId.get(id)?.exifInfo;
// `== null`, not a falsiness check: 0 is the equator or the prime meridian (#635).
if (!exif || exif.latitude == null || exif.longitude == null) return null;
const level = precisionOf(id);
if (level === 'hidden' || level === 'country') return null;
const quantised = applyPrecision({ lat: exif.latitude, lng: exif.longitude }, level);
if (!quantised) return null;
const pos =
level === 'exact'
? {
lat: snapToGrid(quantised.lat, EXACT_STEP, EXACT_DECIMALS),
lng: snapToGrid(quantised.lng, EXACT_STEP, EXACT_DECIMALS),
}
: quantised;
const place = placeLabel(level, { city: exif.city ?? '', country: exif.country ?? '' });
const label = [place.city, place.country].filter(Boolean).join(', ');
return label ? { lat: pos.lat, lng: pos.lng, label } : { lat: pos.lat, lng: pos.lng };
};
for (const item of items) {
if (item.kind === 'point') {
push(
item.label
? { lat: item.lat, lng: item.lng, label: item.label }
: { lat: item.lat, lng: item.lng },
);
} else if (item.kind === 'photo') {
const pin = photoPin(item.assetId);
if (pin) push(pin);
} else {
for (const id of entryPhotoIds) {
if (explicitPhotoIds.has(id)) continue;
const pin = photoPin(id);
if (pin) push(pin);
}
}
}
return pins;
}
/** Server wrapper: each photo's precision resolved the way the EXIF route does. */
export async function entryMapPins(
items: readonly MapItem[],
assets: readonly GeoAsset[],
entryPhotoIds: readonly string[],
): Promise<MapPin[]> {
const wanted = new Set<string>();
for (const item of items) {
if (item.kind === 'photo') wanted.add(item.assetId);
else if (item.kind === 'all-photos') for (const id of entryPhotoIds) wanted.add(id);
}
const ids = Array.from(wanted);
const levels = await Promise.all(ids.map((id) => assetLocationPrecision(id)));
const byId = new Map(ids.map((id, i) => [id, levels[i]]));
return pinsForEntry(items, assets, entryPhotoIds, (id) => byId.get(id) ?? 'exact');
}

192
lib/journalTemplates.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* Starting-point block structures offered in the Journal Studio's Create
* modal, as an alternative to an empty editor. Client-safe (no `fs`), same
* tier as `lib/journal.ts` — importable directly from `JournalStudio.tsx`.
*
* Every photo, pair and grid placeholder leaves its `assetId` as `''`:
* `parseJournalMarkdown` round-trips that as an unfilled placeholder (see the
* comment on the image regex in `lib/journal.ts`), and the block editor
* already renders it as a "+ Pick" tile.
*/
import type { JournalBlock } from './journal';
export interface JournalTemplate {
id: string;
name: string;
description: string;
blocks: JournalBlock[];
}
type PhotoLayout = Extract<JournalBlock, { type: 'photo' }>['layout'];
// Captions and authors default to `undefined`, not `''`: that is what
// `parseJournalMarkdown` produces for an empty `(...)` or a missing `-- author`,
// so a template block must start that way too or the round-trip test sees a
// reshaped block on first load.
const photo = (layout: PhotoLayout, caption?: string): JournalBlock => ({
type: 'photo',
assetId: '',
layout,
caption: caption || undefined,
});
const pair = (caption?: string): JournalBlock => ({
type: 'photo-pair',
assetIds: ['', ''],
caption: caption || undefined,
});
const grid = (count: number, caption?: string): JournalBlock => ({
type: 'photo-grid',
assetIds: Array.from({ length: count }, () => ''),
caption: caption || undefined,
});
// Quote text must not contain ` -- `: that is the author separator.
const quote = (text: string, author?: string): JournalBlock => ({
type: 'quote',
text,
author: author || undefined,
});
// Values are placeholders too — an empty value would be dropped on save.
const facts = (...rows: Array<[label: string, placeholder: string]>): JournalBlock => ({
type: 'facts',
items: rows.map(([label, value]) => ({ label, value })),
});
// Templates opt into "every geotagged photo of the entry"; named stops are
// added in the studio, where a typed point is a label and two numbers.
const map = (caption?: string): JournalBlock => ({
type: 'map',
caption: caption || undefined,
line: true,
items: [{ kind: 'all-photos' }],
});
// An album block with no album yet: the studio shows "Pick album".
const album = (caption?: string): JournalBlock => ({
type: 'album',
albumId: '',
layout: 'grid',
...(caption ? { caption } : {}),
});
const heading = (text: string, level = 2): JournalBlock => ({ type: 'heading', level, text });
const paragraph = (text: string): JournalBlock => ({ type: 'paragraph', html: text });
export const JOURNAL_TEMPLATES: JournalTemplate[] = [
{
id: 'wedding',
name: 'Wedding',
description:
'Date and venue up front, one hero image, a line from the vows, the party as a grid.',
blocks: [
paragraph('[Who, where, and when — a sentence or two.]'),
facts(['Date', '[The date]'], ['Venue', '[Where it took place]']),
photo('fullbleed', '[The image that carries the whole day.]'),
heading('Getting Ready'),
pair('[Rings and dress, side by side.]'),
heading('Ceremony'),
photo('wide', '[The vows, the walk, the kiss.]'),
quote('[The line from the day you want remembered.]', '[Who said it]'),
heading('Reception'),
grid(3, '[First dance, the speeches, the party.]'),
paragraph('[A closing line — no need to wrap it up neatly.]'),
],
},
{
id: 'hiking',
name: 'Hiking / Outdoor',
description: 'Distance and elevation as facts, the trail as a grid, the route on a map.',
blocks: [
paragraph('[Route and conditions — keep it factual.]'),
facts(['Distance', '[21 km]'], ['Elevation', '[1,240 m]'], ['Duration', '[6 h]']),
photo('fullbleed', '[The landscape that sets the scene.]'),
heading('The Trail'),
grid(3, '[Along the way.]'),
photo('wide', '[The summit, or wherever it ended.]'),
map('[The route, from the geotagged photos above.]'),
],
},
{
id: 'travel',
name: 'Travel',
description: 'The trip on a map, then one stop per section with two photos each.',
blocks: [
paragraph('[Where, and over what dates.]'),
map('[Where the trip went, e.g. Busan → Seoul.]'),
heading('[Day or place 1]'),
paragraph('[A short note on this stop.]'),
pair(),
heading('[Day or place 2]'),
paragraph('[A short note on this stop.]'),
pair(),
heading('[Day or place 3]'),
paragraph('[A short note on this stop.]'),
pair(),
],
},
{
id: 'birthday',
name: 'Birthday / Family',
description: 'Almost no text — the whole album as a grid and one full-bleed frame.',
blocks: [
paragraph('[One personal sentence about the day.]'),
album('[The day, as it happened.]'),
photo('fullbleed', '[The one moment worth a full-bleed frame.]'),
],
},
{
id: 'portrait-session',
name: 'Portrait Session',
description: 'Solo wide frames with room to breathe, and a line in their own words.',
blocks: [
paragraph('[Who this session is with, and the idea behind it, in a sentence.]'),
photo('wide'),
quote('[Something they said during the session.]', '[Their name]'),
photo('wide'),
photo('wide'),
],
},
{
id: 'behind-the-scenes',
name: 'Behind the Scenes',
description: 'Setup and shoot as a pair, then the finished result to land on.',
blocks: [
paragraph('[What the assignment or setup was.]'),
pair('[Setting up, and mid-shoot.]'),
photo('contained', '[Another making-of moment.]'),
heading('The Result'),
photo('fullbleed', '[The finished shot.]'),
],
},
{
id: 'pets',
name: 'Pets',
description: 'Candid pairs, one close-up portrait with personality.',
blocks: [
paragraph('[Who they are — name, and whatever makes them them.]'),
pair(),
photo('wide', '[A close-up — the one with personality.]'),
pair(),
],
},
{
id: 'new-series',
name: 'New Series',
description: 'Opens on a statement, then photo-and-text pairs introducing the project.',
blocks: [
quote('[One sentence on why this series exists.]'),
photo('wide'),
paragraph('[A line about this image.]'),
photo('wide'),
paragraph('[A line about this image.]'),
photo('wide'),
paragraph('[A line about this image.]'),
paragraph('[More to come — or however this should close.]'),
],
},
];

View File

@@ -66,6 +66,11 @@ export function quantiseCoordinate(value: number, level: LocationPrecision): num
if (level === 'hidden') return 0; if (level === 'hidden') return 0;
const { step, decimals } = GRID[level]; const { step, decimals } = GRID[level];
if (step === 0) return value; if (step === 0) return value;
return snapToGrid(value, step, decimals);
}
/** Round to a fixed grid of `step` degrees; `decimals` trims the float tail. */
export function snapToGrid(value: number, step: number, decimals: number): number {
return Number((Math.round(value / step) * step).toFixed(decimals)); return Number((Math.round(value / step) * step).toFixed(decimals));
} }

View File

@@ -107,6 +107,18 @@ export function downloadUrl(albumId: string, assetId: string): string {
return `/api/download/${encodeAssetId(albumId)}/${encodeAssetId(assetId)}`; return `/api/download/${encodeAssetId(albumId)}/${encodeAssetId(assetId)}`;
} }
/**
* The endpoint that streams an album (or a selection of its assets) as a ZIP.
*
* Carries only the album: the route re-checks the allowlist, the `download`
* opt-in and every password gate, exactly like the single-asset download. A
* `GET` returns the whole album; a `POST` with `{ "assets": [<token>, …] }`
* returns just those assets.
*/
export function archiveUrl(albumId: string): string {
return `/api/download/${encodeAssetId(albumId)}/archive`;
}
/** /**
* The Immich asset description, trimmed, for use as image alt text. * The Immich asset description, trimmed, for use as image alt text.
* *

View File

@@ -18,8 +18,23 @@ const securityHeaders = [
{ key: 'X-DNS-Prefetch-Control', value: 'off' }, { key: 'X-DNS-Prefetch-Control', value: 'off' },
]; ];
/**
* Dev only: Next 16 answers 403 for every /_next dev asset requested from a
* hostname other than localhost, which kills hydration when the dev server is
* opened over the LAN. `ALLOWED_DEV_ORIGINS` in `.env.local` lists the extra
* hostnames, comma-separated — hostname only, scheme and port are ignored.
* Read here rather than through `lib/env.ts`, which validates the app's
* runtime settings and would demand AUTH_SECRET just to start `next dev`;
* Next loads `.env.local` before it evaluates this file.
*/
const allowedDevOrigins = (process.env.ALLOWED_DEV_ORIGINS ?? '')
.split(',')
.map((host) => host.trim())
.filter(Boolean);
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
allowedDevOrigins,
images: { images: {
loader: 'custom', loader: 'custom',
loaderFile: './lib/immichLoader.ts', loaderFile: './lib/immichLoader.ts',

922
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-folio", "name": "immich-folio",
"version": "0.16.0", "version": "0.17.0",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -21,6 +21,7 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"archiver": "^7.0.1",
"js-yaml": "^4.3.1", "js-yaml": "^4.3.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"next": "16.3.5", "next": "16.3.5",
@@ -32,6 +33,7 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.63.0", "@playwright/test": "^1.63.0",
"@testing-library/react": "^16.3.3", "@testing-library/react": "^16.3.3",
"@types/archiver": "^6.0.4",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.22", "@types/leaflet": "^1.9.22",
"@types/node": "^20", "@types/node": "^20",
@@ -44,7 +46,7 @@
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"prettier": "^3.9.6", "prettier": "^3.9.8",
"typescript": "^5", "typescript": "^5",
"vitest": "^4.0.18" "vitest": "^4.0.18"
} }

1050
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff