release: v0.17.0 (#677)
See CHANGELOG.md, section [0.17.0] — 2026-09-23.
This commit is contained in:
@@ -68,6 +68,12 @@ AUTH_SECRET=change-me-to-a-long-random-string
|
||||
# Leave unset or empty to disable the admin UI entirely.
|
||||
# ADMIN_PASSWORD=your-secure-admin-password
|
||||
|
||||
# ── Development over the LAN (next dev only) ──────────────────────
|
||||
# `next dev` refuses its own assets to any hostname but localhost, so the
|
||||
# page never hydrates when opened as http://192.168.1.20:3000. List the
|
||||
# hostnames or IPs you open it from, comma-separated. No effect in production.
|
||||
# ALLOWED_DEV_ORIGINS=192.168.1.20,myhost.local
|
||||
|
||||
# ── Site URL ─────────────────────────────────────────────────────
|
||||
# Used for sitemap.xml, the feed and structured data when `url:` is not
|
||||
# set in settings.yaml. The settings file takes precedence, so the admin
|
||||
|
||||
113
CHANGELOG.md
113
CHANGELOG.md
@@ -7,6 +7,119 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
Releases up to and including v0.9.2 are documented in the
|
||||
[GitHub releases](https://github.com/ralksta/immich-folio/releases).
|
||||
|
||||
## [0.17.0] — 2026-09-23
|
||||
|
||||
### Added
|
||||
|
||||
- **Download an album, or a proofing selection, as a ZIP of the originals**
|
||||
([#561](https://github.com/ralksta/immich-folio/pull/561),
|
||||
[#665](https://github.com/ralksta/immich-folio/pull/665)) — contributed by
|
||||
[@lancetm714](https://github.com/lancetm714). An album with
|
||||
`download: true` — now also a switch in the admin album editor, _Allow
|
||||
original downloads_ — shows a _Download album_ link in its header, and the
|
||||
proofing dialog gains _Download selected (.zip)_. The archive streams one
|
||||
original at a time, uncompressed, so memory stays flat however large the
|
||||
album is, and a cancelled download stops pulling from Immich. The route
|
||||
re-checks everything the single-file download does — the allowlist, the
|
||||
`download` opt-in, every password gate on the way to the album — and a
|
||||
selection may name only assets of that album, each once, at most 1,000.
|
||||
Downloads are limited to five a minute per visitor; a refusal answers a
|
||||
browser with a short page in the site's language that links back to the
|
||||
album, instead of raw JSON.
|
||||
|
||||
- **Journal entries can start from a template**
|
||||
([#662](https://github.com/ralksta/immich-folio/pull/662)). Besides a blank
|
||||
entry, the _New Journal Entry_ dialog offers eight structures: Wedding,
|
||||
Hiking / Outdoor, Travel, Birthday / Family, Portrait Session, Behind the
|
||||
Scenes, Pets and New Series.
|
||||
|
||||
- **Four new journal blocks, and drag-and-drop ordering**
|
||||
([#663](https://github.com/ralksta/immich-folio/pull/663)):
|
||||
- **Photo grid** — three or more photos in rows of three, at their real
|
||||
proportions. A third photo used to be dropped silently on the next save.
|
||||
- **Facts** — label/value pairs, rendered as a definition list.
|
||||
- **Map** — the pins the author lists: typed coordinates, a photo placed by
|
||||
its GPS, or every geotagged photo of the entry, optionally joined by a
|
||||
line. Photo pins follow the album's location precision, and `exact` is
|
||||
rounded to a 1 km grid. Only the pins reach the browser, never asset ids.
|
||||
- **Album** — a slice of an album (`count`, `skip`, laid out as a grid, in
|
||||
pairs or one per row), expanded into ordinary photos before rendering.
|
||||
|
||||
The Journal Studio and the page builder's essay editor both get the new
|
||||
blocks, and every block card can be dragged by its grip; the arrow buttons
|
||||
stay for the keyboard. The templates use the new blocks.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Admin login and gallery passwords work over plain HTTP**
|
||||
([#666](https://github.com/ralksta/immich-folio/pull/666), reported in
|
||||
[#664](https://github.com/ralksta/immich-folio/issues/664) by
|
||||
[@majandres](https://github.com/majandres)). Login cookies were marked
|
||||
`Secure` whenever the app ran in production mode, which the Docker image
|
||||
always does, and a browser silently drops a `Secure` cookie set over
|
||||
`http://`. An instance opened at `http://host:7211` accepted the password
|
||||
and answered every following request with 401. The flag now follows the
|
||||
request: HTTPS, or `X-Forwarded-Proto: https` from a reverse proxy.
|
||||
|
||||
- **Unsaved edits in the page builder and the journal editor survive leaving
|
||||
the page** ([#668](https://github.com/ralksta/immich-folio/pull/668)).
|
||||
Switching admin tabs, pressing back, reloading or logging out used to
|
||||
discard them without a word. Each editor now keeps a draft for the browser
|
||||
tab and restores it on return, with a notice and a _Discard changes_
|
||||
button. If the file was saved elsewhere in the meantime, the draft is held
|
||||
back and the editor asks before restoring it over the newer version.
|
||||
|
||||
- **Favourites are kept per album**
|
||||
([#561](https://github.com/ralksta/immich-folio/pull/561)). Every album
|
||||
shared one selection, so hearts set in one album showed up in the next.
|
||||
See the upgrade notes.
|
||||
|
||||
- **The proofing dialog's copy buttons work without a clipboard**
|
||||
([#672](https://github.com/ralksta/immich-folio/pull/672)). Browsers offer
|
||||
the clipboard only on HTTPS, so on a site reached over plain HTTP _Copy
|
||||
shareable link_ and _Copy text list_ did nothing. They now show the link or
|
||||
the list, selected, in a field to copy by hand — as the image viewer
|
||||
already did for its photo link.
|
||||
|
||||
- **English leftovers on translated sites**
|
||||
([#673](https://github.com/ralksta/immich-folio/pull/673),
|
||||
[#674](https://github.com/ralksta/immich-folio/pull/674)): the back link of
|
||||
an album inside a subpage read "Back to …", and screen readers heard
|
||||
English labels on photo tiles and footer links.
|
||||
|
||||
### Internal
|
||||
|
||||
- **The journal editor is split up**
|
||||
([#669](https://github.com/ralksta/immich-folio/pull/669),
|
||||
[#670](https://github.com/ralksta/immich-folio/pull/670)). The 2,000-line
|
||||
`JournalStudio.tsx` is now the entry list and an editor of about 600
|
||||
lines, with the block forms, story settings, live preview, split pane and
|
||||
block operations in files of their own and the extracted logic under test.
|
||||
Screenshots before and after are pixel-identical.
|
||||
- **`next dev` over the LAN**
|
||||
([#671](https://github.com/ralksta/immich-folio/pull/671)): the hostnames
|
||||
allowed to load dev assets come from `ALLOWED_DEV_ORIGINS` in `.env.local`
|
||||
instead of the config file.
|
||||
- Prettier 3.9.8 ([#625](https://github.com/ralksta/immich-folio/pull/625),
|
||||
[#667](https://github.com/ralksta/immich-folio/pull/667)).
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
**Visitors' favourites from before this release are not carried over.** They
|
||||
were stored under one key shared by every album; each album now has its own,
|
||||
and the old key is no longer read. A selection in progress has to be made
|
||||
again. A shared proofing link still restores the selection it carries.
|
||||
|
||||
**Behind a reverse proxy, make sure it sends `X-Forwarded-Proto`**, so login
|
||||
cookies stay `Secure` on HTTPS. Caddy and Traefik send it on their own; nginx
|
||||
needs `proxy_set_header X-Forwarded-Proto $scheme;`, as in the documented
|
||||
config. Plain-HTTP setups need nothing — logins simply work now.
|
||||
|
||||
**Running from source:** run `npm install` after pulling — the ZIP download
|
||||
adds the `archiver` dependency. Docker images include it.
|
||||
|
||||
No `IMAGE_CACHE_VERSION` bump is needed this time.
|
||||
|
||||
## [0.16.0] — 2026-09-22
|
||||
|
||||
### Security
|
||||
|
||||
@@ -72,7 +72,7 @@ See `app/admin/__tests__/SaveBar.test.tsx` for a worked example.
|
||||
**Prefer extracting the logic over rendering it.** A pure function moved into
|
||||
`lib/` — or into a sibling module, as `page-builder/albumEntries.ts` was — is
|
||||
cheaper to test and stays tested when the markup around it changes. Reach for a
|
||||
component test when the behaviour *is* the rendering: a state that hides a form,
|
||||
component test when the behaviour _is_ the rendering: a state that hides a form,
|
||||
a control that must stay disabled, a keyboard shortcut.
|
||||
|
||||
## Code style
|
||||
|
||||
10
README.md
10
README.md
@@ -60,11 +60,12 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **
|
||||
- **YAML gallery config** — all gallery structure defined in a single `content/gallery.yaml` file
|
||||
- **Markdown about page** — `content/about.md` with frontmatter for portrait, name, location, and gear list, editable from the admin panel
|
||||
- **Journal** — photo essays and travel stories at `/journal`, with drafts, per-entry passwords, cover images and reading times
|
||||
- **Photo Essay mode** — long-form storytelling pages alternating text with fullbleed and paired image layouts
|
||||
- **Photo Essay mode** — long-form storytelling pages alternating text with fullbleed, paired and grid image layouts, facts lists and a map of the entry's geotagged photos
|
||||
- **Unlisted subpages** _(experimental)_ — reachable by direct link, absent from the navigation
|
||||
- **Subpage on/off toggle** — take a page offline without deleting it
|
||||
- **External navigation links** _(experimental)_ — point the header at a shop, a blog, or a social profile
|
||||
- **Client proofing** — clients favorite photos and export the selection; picks are encoded in the URL, nothing is stored server-side
|
||||
- **Originals delivery** — per-album opt-in lets visitors download a whole album or their proofing selection as a ZIP of the originals
|
||||
- **Lightbox watermark** — configurable overlay on fullscreen images
|
||||
- **Privacy-friendly analytics** — cookieless view counts, no third parties, can be switched off
|
||||
- **Dynamic OG images** — auto-generated social preview images per album
|
||||
@@ -244,7 +245,7 @@ All gallery structure — hero images, albums, subpages, grid layout, footer —
|
||||
|
||||
### Journal & Photo Essays
|
||||
|
||||
Long-form storytelling with fullbleed photos, side-by-side pairs, quotes and captions — as a standalone `/journal` section, or as a single essay on one subpage.
|
||||
Long-form storytelling with fullbleed photos, side-by-side pairs and grids, quotes, facts lists, a map of the entry's own geotagged photos and captions — as a standalone `/journal` section, or as a single essay on one subpage. New entries can start from a template.
|
||||
|
||||
→ **[Journal & Photo Essays Guide](docs/journal.md)**
|
||||
|
||||
@@ -369,7 +370,10 @@ easier to live with.
|
||||
in the browser tab, and made a gallery with no albums yet a perfectly valid
|
||||
starting point rather than an error. In v0.15.0, made client proofing work
|
||||
properly: the selection bar floats again with page transitions on, and it is
|
||||
readable in light mode. Also brought links to journal quote attributions.
|
||||
readable in light mode. Also brought links to journal quote attributions. In
|
||||
v0.17.0, built the ZIP download: a whole album, or just the photos a client
|
||||
picked, as one archive of the originals — streamed, so even a large shoot
|
||||
arrives complete.
|
||||
- **[@ImScheinox](https://github.com/ImScheinox)** — found and fixed portrait
|
||||
photos rendering as landscape tiles, because the grid ignored the camera's
|
||||
EXIF orientation flag. That brought the masonry layout's stagger back for
|
||||
|
||||
@@ -31,6 +31,12 @@ interface AlbumDetailViewProps {
|
||||
proofing?: boolean;
|
||||
/** Offer the "send by email" button in the proofing modal. */
|
||||
allowMailto?: boolean;
|
||||
/**
|
||||
* The ZIP endpoint for this album, when it offers downloads (#475). Renders a
|
||||
* "download album" link in the header and lets the proofing modal offer a
|
||||
* "download selected" action.
|
||||
*/
|
||||
downloadArchiveUrl?: string;
|
||||
/** Neighbouring albums in the surrounding list, for the foot navigation. */
|
||||
nav?: AlbumNavPair;
|
||||
/** JSON-LD for this album, or null when no site URL is configured (#472). */
|
||||
@@ -52,6 +58,7 @@ export function AlbumDetailView({
|
||||
showGear = true,
|
||||
proofing,
|
||||
allowMailto,
|
||||
downloadArchiveUrl,
|
||||
nav,
|
||||
structuredData,
|
||||
}: AlbumDetailViewProps) {
|
||||
@@ -87,6 +94,24 @@ export function AlbumDetailView({
|
||||
</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>
|
||||
<p className="album-header__meta">
|
||||
<span className="album-header__meta-count">{t.common.photos(images.length)}</span>
|
||||
@@ -102,6 +127,8 @@ export function AlbumDetailView({
|
||||
showExifPanel={showExifPanel}
|
||||
proofing={proofing}
|
||||
allowMailto={allowMailto}
|
||||
downloadArchiveUrl={downloadArchiveUrl}
|
||||
albumName={album.albumName}
|
||||
/>
|
||||
{nav && <AlbumNav {...nav} />}
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
|
||||
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
|
||||
import { ProofingModal } from '@/components/ProofingModal';
|
||||
import { FadeIn } from '@/components/FadeIn';
|
||||
import { LeafletMap, escapeHtml } from '@/components/LeafletMap';
|
||||
import type { ParsedEssay, EssayBlock } from '@/lib/essay';
|
||||
import { renderInlineMarkdown } from '@/lib/essay';
|
||||
import type { PhotoItem } from './PhotoGrid';
|
||||
@@ -59,6 +60,77 @@ function EssayViewContent({
|
||||
? assetMap.get(essay.frontmatter.coverAssetId)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* One photo tile of a pair or a grid: sized by its real ratio (see
|
||||
* .essay-pair-grid), opens the lightbox, carries the proofing heart. The pair
|
||||
* used to spell this out a second time next to the photo case; a third copy
|
||||
* for the grid is where that stops.
|
||||
*/
|
||||
const renderTile = ({ item, index }: { item: PhotoItem; index: number }, key: number) => {
|
||||
const isFav = proofing ? proofing.isFavorite(item.id) : false;
|
||||
return (
|
||||
<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) => {
|
||||
switch (block.type) {
|
||||
case 'heading': {
|
||||
@@ -183,72 +255,42 @@ function EssayViewContent({
|
||||
return (
|
||||
<FadeIn key={idx}>
|
||||
<div className="essay-figure essay-figure--wide">
|
||||
<div className="essay-pair-grid">
|
||||
{[res1, res2].map(({ item, index }, pIdx) => {
|
||||
const isFav = proofing ? proofing.isFavorite(item.id) : false;
|
||||
return (
|
||||
<div
|
||||
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 className="essay-pair-grid">{[res1, res2].map(renderTile)}</div>
|
||||
{block.caption && (
|
||||
<figcaption
|
||||
className="essay-figcaption"
|
||||
dangerouslySetInnerHTML={{ __html: block.caption }}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
{block.caption && (
|
||||
<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:
|
||||
return null;
|
||||
}
|
||||
@@ -399,7 +505,13 @@ export function EssayView(props: EssayViewProps) {
|
||||
}
|
||||
|
||||
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} />
|
||||
</ProofingProvider>
|
||||
);
|
||||
|
||||
@@ -60,6 +60,17 @@ interface PhotoGridProps {
|
||||
proofing?: boolean;
|
||||
/** Offer the "send by email" button in the proofing modal. */
|
||||
allowMailto?: boolean;
|
||||
/**
|
||||
* The ZIP endpoint for this album, when it offers downloads. Lets the
|
||||
* proofing modal offer a "download selected" action (#475).
|
||||
*/
|
||||
downloadArchiveUrl?: string;
|
||||
/**
|
||||
* The album's display name. Scopes the proofing favourites to this album —
|
||||
* without it every album shares the provider's default storage key, so a
|
||||
* selection made in one album leaks into the next.
|
||||
*/
|
||||
albumName?: string;
|
||||
}
|
||||
|
||||
function PhotoGridInner({
|
||||
@@ -193,7 +204,7 @@ function PhotoGridInner({
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`View photo ${index + 1}`}
|
||||
aria-label={t.lightbox.openPhoto(index + 1)}
|
||||
aria-haspopup="dialog"
|
||||
style={{
|
||||
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
|
||||
@@ -366,7 +377,12 @@ export function PhotoGrid(props: PhotoGridProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ProofingProvider albumTokens={albumTokens} allowMailto={props.allowMailto ?? true}>
|
||||
<ProofingProvider
|
||||
albumTokens={albumTokens}
|
||||
albumName={props.albumName}
|
||||
allowMailto={props.allowMailto ?? true}
|
||||
downloadArchiveUrl={props.downloadArchiveUrl}
|
||||
>
|
||||
<PhotoGridInner {...props} />
|
||||
</ProofingProvider>
|
||||
);
|
||||
|
||||
@@ -200,3 +200,87 @@
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Map: the entry's geotagged photos as pins, a story-sized Leaflet map. */
|
||||
.essay-map {
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
border-radius: var(--radius-sm, 0);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.15));
|
||||
background: var(--bg-secondary, #222);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.essay-map {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Facts: a short definition list in the text column — distance, venue, date. */
|
||||
.essay-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem 2rem;
|
||||
max-width: var(--essay-contained, 680px);
|
||||
margin: 2.5rem auto;
|
||||
padding: 1.25rem 0;
|
||||
border-top: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
|
||||
border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
|
||||
}
|
||||
|
||||
.essay-facts__item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.essay-facts__label {
|
||||
font-family: var(--font-caption);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.essay-facts__value {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.essay-facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Grid: rows of up to three, each row justified exactly like a pair. */
|
||||
.essay-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: var(--essay-wide);
|
||||
margin: 3.5rem auto;
|
||||
}
|
||||
|
||||
.essay-grid-row {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.essay-grid-row > .essay-image-wrapper {
|
||||
flex-basis: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.essay-grid-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.essay-grid-row > .essay-image-wrapper {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
assetCaption,
|
||||
assetExifSummary,
|
||||
downloadUrl,
|
||||
archiveUrl,
|
||||
assetAspectRatio,
|
||||
} from '@/lib/urls';
|
||||
import { encodeAssetId, decodeAssetId } from '@/lib/tokens';
|
||||
@@ -44,7 +45,11 @@ import { albumStructuredData } from '@/lib/structuredData';
|
||||
import { absoluteUrl } from '@/lib/siteUrl';
|
||||
import { SubpageGridView } from './SubpageGridView';
|
||||
import { EssayView } from './EssayView';
|
||||
import { parseEssayMarkdown } from '@/lib/essay';
|
||||
import { parseEssayMarkdown, type EssayBlock } from '@/lib/essay';
|
||||
import { pinsForEntry } from '@/lib/journalMap';
|
||||
import { expandAlbumBlocks } from '@/lib/journalAlbum';
|
||||
import { mapBlockAssetIds } from '@/lib/journal';
|
||||
import { strictestPrecision, type LocationPrecision } from '@/lib/mapPrecision';
|
||||
import { loadEssayFromFile } from '@/lib/admin/journal-service';
|
||||
import { getServerDictionary } from '@/lib/i18n/server';
|
||||
|
||||
@@ -374,12 +379,13 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
layout={resolveLayout(mergeAlbumGrid(album.id, spGrid))}
|
||||
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, spGrid))}
|
||||
backLinkHref={`/${subpageSlug}`}
|
||||
backLinkLabel={`Back to ${subpageName}`}
|
||||
backLinkLabel={getServerDictionary().common.backTo(subpageName)}
|
||||
watermark={config.watermark}
|
||||
showExifPanel={hasExifPanelContent(config.exif)}
|
||||
showGear={config.exif.camera}
|
||||
proofing={proofingFor(subpageData?.subpage)}
|
||||
allowMailto={config.proofing.allowMailto}
|
||||
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
@@ -433,6 +439,43 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
openAlbums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
|
||||
);
|
||||
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
|
||||
|
||||
// Album blocks in an essay file: expand them the way the journal page
|
||||
// does, from a raw fetch, and add those photos to the page's assets.
|
||||
// Their ids are encoded here because this path hands EssayView tokens.
|
||||
if (essayParsed?.blocks.some((b) => b.type === 'album')) {
|
||||
const byAlbum = new Map<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(
|
||||
allAssets,
|
||||
config.exif.onHover && config.exif.camera,
|
||||
@@ -459,6 +502,32 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
};
|
||||
}
|
||||
|
||||
// Map blocks: pins from the subpage's own albums, each album's
|
||||
// `location:` precision applied (an asset in two albums takes the
|
||||
// stricter). Dropped entirely when the site has no map.
|
||||
if (essayParsed.blocks.some((b) => b.type === 'map')) {
|
||||
const precisionByAsset = new Map<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
|
||||
// proofing controls when its subpage asks for them explicitly — the
|
||||
// global default does not reach in here.
|
||||
@@ -521,6 +590,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
showGear={config.exif.camera}
|
||||
proofing={proofingFor(result.subpage)}
|
||||
allowMailto={config.proofing.allowMailto}
|
||||
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
@@ -622,6 +692,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
showGear={config.exif.camera}
|
||||
proofing={proofingFor()}
|
||||
allowMailto={config.proofing.allowMailto}
|
||||
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
|
||||
126
app/admin/__tests__/journal-editor-parts.test.ts
Normal file
126
app/admin/__tests__/journal-editor-parts.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
157
app/admin/__tests__/useDraft.test.tsx
Normal file
157
app/admin/__tests__/useDraft.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import type { JournalBlock } from '@/lib/journal';
|
||||
import { IconFileText, IconSparkles, IconQuote, IconCamera, IconArrowLeftRight } from './Icons';
|
||||
import {
|
||||
IconFileText,
|
||||
IconSparkles,
|
||||
IconQuote,
|
||||
IconCamera,
|
||||
IconArrowLeftRight,
|
||||
IconGrid,
|
||||
IconColumns,
|
||||
IconMap,
|
||||
IconFolder,
|
||||
} from './Icons';
|
||||
|
||||
type BlockType = JournalBlock['type'];
|
||||
|
||||
@@ -19,6 +29,10 @@ const BLOCK_META: Record<BlockType, { label: string; Icon: typeof IconFileText }
|
||||
quote: { label: 'Quote', Icon: IconQuote },
|
||||
photo: { label: 'Photo', Icon: IconCamera },
|
||||
'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 {
|
||||
|
||||
61
app/admin/components/DraftNotice.tsx
Normal file
61
app/admin/components/DraftNotice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
} from './page-builder/types';
|
||||
import { useScrollLock } from './useScrollLock';
|
||||
import { useUnsavedGuard } from './useUnsavedGuard';
|
||||
import { useDraft } from './useDraft';
|
||||
import DraftNotice from './DraftNotice';
|
||||
import { reportIfSessionExpired } from './sessionExpiry';
|
||||
import {
|
||||
IconCamera,
|
||||
@@ -273,6 +275,11 @@ export default function PageBuilder() {
|
||||
|
||||
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
|
||||
// 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
|
||||
@@ -312,8 +319,11 @@ export default function PageBuilder() {
|
||||
|
||||
const { gallery: raw } = await galleryRes.json();
|
||||
const parsed = parseGalleryYaml(raw);
|
||||
setGallery(parsed);
|
||||
openAlbumFromLink(parsed);
|
||||
serverState.current = 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
|
||||
// saving with it empty changes nothing in gallery.yaml.
|
||||
@@ -418,6 +428,20 @@ export default function PageBuilder() {
|
||||
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(() => {
|
||||
setDirty(true);
|
||||
setSaveMessage('');
|
||||
@@ -482,6 +506,13 @@ export default function PageBuilder() {
|
||||
|
||||
if (res.ok) {
|
||||
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);
|
||||
setSaveMessage(data.message || 'Saved successfully!');
|
||||
setTimeout(() => setSaveMessage(''), 5000);
|
||||
@@ -850,6 +881,14 @@ export default function PageBuilder() {
|
||||
showPreview
|
||||
/>
|
||||
|
||||
<DraftNotice
|
||||
status={draft.status}
|
||||
subject="page structure"
|
||||
onDiscard={discardDraft}
|
||||
onRestore={restoreConflictingDraft}
|
||||
onDismiss={draft.dismiss}
|
||||
/>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="builder-search-container">
|
||||
<div className="builder-search-wrapper">
|
||||
|
||||
116
app/admin/components/SortableBlocks.tsx
Normal file
116
app/admin/components/SortableBlocks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
684
app/admin/components/journal/BlockFields.tsx
Normal file
684
app/admin/components/journal/BlockFields.tsx
Normal 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 "{block.assetId}" 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 "{block.assetIds.filter(isLegacyAssetRef).join('", "')}
|
||||
" 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 "{block.assetIds.filter(isLegacyAssetRef).join('", "')}
|
||||
" 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'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's GPS, under the album's location precision.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
607
app/admin/components/journal/JournalEditor.tsx
Normal file
607
app/admin/components/journal/JournalEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
app/admin/components/journal/JournalPreview.tsx
Normal file
123
app/admin/components/journal/JournalPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
382
app/admin/components/journal/JournalStudio.tsx
Normal file
382
app/admin/components/journal/JournalStudio.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
188
app/admin/components/journal/StorySettingsModal.tsx
Normal file
188
app/admin/components/journal/StorySettingsModal.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
66
app/admin/components/journal/blockOps.ts
Normal file
66
app/admin/components/journal/blockOps.ts
Normal 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;
|
||||
}
|
||||
@@ -395,6 +395,188 @@
|
||||
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 ───────────────────────────────────────────── */
|
||||
|
||||
.journal-admin-card-meta span {
|
||||
109
app/admin/components/journal/splitPane.ts
Normal file
109
app/admin/components/journal/splitPane.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -100,6 +100,43 @@ export default function AlbumDrawer({
|
||||
/>
|
||||
</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 className="admin-sheet-col admin-sheet-col--divided">
|
||||
|
||||
135
app/admin/components/useDraft.ts
Normal file
135
app/admin/components/useDraft.ts
Normal 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 };
|
||||
}
|
||||
@@ -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({
|
||||
params,
|
||||
@@ -6,5 +7,5 @@ export default async function AdminJournalEntryPage({
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
return <JournalStudio slug={slug} />;
|
||||
return <JournalStudio slug={slug} mapEnabled={getConfig().map} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { JournalStudio } from '../components/JournalStudio';
|
||||
import { JournalStudio } from '../components/journal/JournalStudio';
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export default function AdminJournalPage() {
|
||||
return <JournalStudio />;
|
||||
return <JournalStudio mapEnabled={getConfig().map} />;
|
||||
}
|
||||
|
||||
@@ -310,3 +310,18 @@
|
||||
font-size: 0.8rem;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,45 @@
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
border-color: var(--admin-border-hover);
|
||||
color: var(--admin-text);
|
||||
|
||||
@@ -81,6 +81,14 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
|
||||
name: 'GET /api/og',
|
||||
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', () => {
|
||||
|
||||
61
app/api/admin/__tests__/auth-cookie.test.ts
Normal file
61
app/api/admin/__tests__/auth-cookie.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MAX_PASSWORD_LENGTH,
|
||||
} from '@/lib/admin/auth';
|
||||
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. */
|
||||
const ADMIN_AUTH_RPM = 5;
|
||||
@@ -53,7 +54,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
response.cookies.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: isHttpsRequest(request),
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: SESSION_DURATION_MS / 1000,
|
||||
@@ -73,11 +74,11 @@ export async function GET() {
|
||||
}
|
||||
|
||||
/** DELETE: Logout. */
|
||||
export async function DELETE() {
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const response = NextResponse.json({ success: true });
|
||||
response.cookies.set(COOKIE_NAME, '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: isHttpsRequest(request),
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
|
||||
@@ -107,7 +107,10 @@ export const GET = withAdmin(async (request: NextRequest) => {
|
||||
// it), which is only known once Immich has answered (#632).
|
||||
const slugGroups: AlbumSlugGroup[] = [
|
||||
{ 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));
|
||||
}
|
||||
|
||||
@@ -82,7 +82,10 @@ export async function POST(req: NextRequest) {
|
||||
{ error: 'Too many requests' },
|
||||
{
|
||||
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
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
/** 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) {
|
||||
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||
}
|
||||
|
||||
313
app/api/download/[album]/archive/__tests__/route.test.ts
Normal file
313
app/api/download/[album]/archive/__tests__/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
422
app/api/download/[album]/archive/route.ts
Normal file
422
app/api/download/[album]/archive/route.ts
Normal 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 === '&'
|
||||
? '&'
|
||||
: char === '<'
|
||||
? '<'
|
||||
: char === '>'
|
||||
? '>'
|
||||
: char === '"'
|
||||
? '"'
|
||||
: ''',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
@import url('./themes/studio-modern.css');
|
||||
|
||||
@import url('./tokens.css');
|
||||
@import url('./leaflet.css');
|
||||
|
||||
/* ── Skip to content link ─────────────────────────── */
|
||||
.skip-link {
|
||||
|
||||
@@ -2,7 +2,15 @@ import { notFound } from 'next/navigation';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { Metadata } from 'next';
|
||||
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 { isAuthenticated } from '@/lib/auth';
|
||||
import { journalNeighbours } from '@/lib/journalNav';
|
||||
@@ -114,7 +122,7 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { frontmatter, blocks, referencedAssetIds } = entry.parsed;
|
||||
const { frontmatter, blocks: authoredBlocks, referencedAssetIds } = entry.parsed;
|
||||
const isAuthedAdmin = await isAdminAuthenticated();
|
||||
|
||||
// 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
|
||||
// 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,
|
||||
// and a standalone journal entry has none.
|
||||
if (rawAssets.length < referencedAssetIds.length) {
|
||||
const resolved = new Set(rawAssets.map((a) => a.id));
|
||||
if (fetchedAssets.length < referencedAssetIds.length) {
|
||||
const resolved = new Set(fetchedAssets.map((a) => a.id));
|
||||
const missing = referencedAssetIds.filter((id) => !resolved.has(id));
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
@@ -191,6 +230,28 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
|
||||
const tokenByAssetId = new Map(rawAssets.map((a) => [a.id, encodeAssetId(a.id)]));
|
||||
const toToken = (assetId: string) => tokenByAssetId.get(assetId) ?? '';
|
||||
|
||||
// A map block's pins are computed here, from the photos fetched above and
|
||||
// under each photo's `location:` precision, and only when the site
|
||||
// publishes a map at all. 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 = {
|
||||
// Named fields, not a spread of `frontmatter`: EssayView reads only
|
||||
// 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,
|
||||
coverAssetId: frontmatter.coverAssetId ? toToken(frontmatter.coverAssetId) : undefined,
|
||||
},
|
||||
blocks: blocks.map((block) => {
|
||||
if (block.type === 'photo') {
|
||||
return { ...block, assetId: toToken(block.assetId) };
|
||||
blocks: blocks.flatMap((block): JournalBlock[] => {
|
||||
if (block.type === 'map') {
|
||||
const pins = pinsByBlock.get(block);
|
||||
return pins
|
||||
? [{ type: 'map', caption: block.caption, line: block.line, items: [], pins }]
|
||||
: [];
|
||||
}
|
||||
if (block.type === 'photo-pair') {
|
||||
return {
|
||||
...block,
|
||||
assetIds: [toToken(block.assetIds[0]), toToken(block.assetIds[1])] as [string, string],
|
||||
};
|
||||
}
|
||||
return block;
|
||||
return [mapBlockAssetIds(block, toToken)];
|
||||
}),
|
||||
referencedAssetIds: rawAssets.map((a) => encodeAssetId(a.id)),
|
||||
};
|
||||
|
||||
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) => {
|
||||
const ph = assetPlaceholder(a);
|
||||
const exif =
|
||||
|
||||
131
app/leaflet.css
Normal file
131
app/leaflet.css
Normal 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;
|
||||
}
|
||||
129
app/map/map.css
129
app/map/map.css
@@ -1,4 +1,6 @@
|
||||
/* ── Map Page ──────────────────────────────────────────────── */
|
||||
/* Marker, popup and Leaflet control styling is shared with journal map
|
||||
blocks and lives in app/leaflet.css. */
|
||||
|
||||
.map-page {
|
||||
display: flex;
|
||||
@@ -52,130 +54,3 @@
|
||||
font-family: var(--font-sans);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -33,8 +33,9 @@ export default function MapPage() {
|
||||
|
||||
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
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
|
||||
@@ -59,7 +59,7 @@ export function Footer() {
|
||||
</a>
|
||||
)}
|
||||
{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
|
||||
aria-hidden="true"
|
||||
width="16"
|
||||
@@ -82,7 +82,7 @@ export function Footer() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="footer__link"
|
||||
aria-label="Website"
|
||||
aria-label={t.common.website}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
|
||||
150
components/LeafletMap.tsx
Normal file
150
components/LeafletMap.tsx
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,13 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* MapView — client component that initializes Leaflet, fetches /api/map,
|
||||
* and renders location-level markers with themed popups.
|
||||
* MapView — fetches /api/map and renders its location-level markers with
|
||||
* 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';
|
||||
|
||||
/** Escapes special HTML characters to prevent XSS in Leaflet popup templates. */
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
import { LeafletMap, escapeHtml, type LeafletMarker } from './LeafletMap';
|
||||
|
||||
interface MapLocationPublic {
|
||||
city: string;
|
||||
@@ -30,21 +21,14 @@ interface MapLocationPublic {
|
||||
|
||||
export function MapView() {
|
||||
const t = useDictionary();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<unknown>(null);
|
||||
const [locations, setLocations] = useState<MapLocationPublic[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function init() {
|
||||
// Dynamically import Leaflet (client-only)
|
||||
const L = (await import('leaflet')).default;
|
||||
|
||||
if (cancelled || !containerRef.current || mapRef.current) return;
|
||||
|
||||
// Fetch map data
|
||||
async function load() {
|
||||
const res = await fetch('/api/map');
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
@@ -54,48 +38,32 @@ export function MapView() {
|
||||
/* ignore */
|
||||
}
|
||||
console.error('[Map] API error', res.status, detail);
|
||||
setError(t.map.loadFailed(res.status));
|
||||
setLoading(false);
|
||||
if (!cancelled) {
|
||||
setError(t.map.loadFailed(res.status));
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const locations: MapLocationPublic[] = await res.json();
|
||||
const data: MapLocationPublic[] = await res.json();
|
||||
if (!cancelled) setLocations(data);
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// Initialize map
|
||||
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:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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);
|
||||
load().catch((err) => {
|
||||
console.error('[Map] Init error:', err);
|
||||
if (!cancelled) {
|
||||
setError(t.map.initFailed);
|
||||
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
|
||||
.map(
|
||||
(a) =>
|
||||
@@ -111,7 +79,11 @@ export function MapView() {
|
||||
const heading = 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">
|
||||
<img src="${escapeHtml(loc.coverUrl)}" alt="${escapeHtml(heading)}" class="map-popup__cover" loading="lazy" />
|
||||
<div class="map-popup__body">
|
||||
@@ -121,43 +93,11 @@ export function MapView() {
|
||||
<div class="map-popup__albums">${albumLinks}</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) {
|
||||
return (
|
||||
@@ -213,7 +153,18 @@ export function MapView() {
|
||||
`}</style>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ interface ProofingContextType {
|
||||
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
getProofingUrl: () => string;
|
||||
getFormattedList: () => string;
|
||||
/** Selected asset tokens in album order — the payload for a ZIP download. */
|
||||
getSelectedTokens: () => string[];
|
||||
allowMailto: boolean;
|
||||
/** The ZIP endpoint when the album offers downloads; undefined otherwise. */
|
||||
downloadArchiveUrl?: string;
|
||||
}
|
||||
|
||||
const ProofingContext = createContext<ProofingContextType | null>(null);
|
||||
@@ -25,11 +29,13 @@ export function ProofingProvider({
|
||||
albumTokens = [],
|
||||
albumName = 'Gallery',
|
||||
allowMailto = true,
|
||||
downloadArchiveUrl,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
albumTokens?: string[];
|
||||
albumName?: string;
|
||||
allowMailto?: boolean;
|
||||
downloadArchiveUrl?: string;
|
||||
}) {
|
||||
const t = useDictionary();
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
||||
@@ -121,6 +127,8 @@ export function ProofingProvider({
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const getSelectedTokens = () => albumTokens.filter((token) => favorites.has(token));
|
||||
|
||||
const getFormattedList = () => {
|
||||
const selectedIndices: number[] = [];
|
||||
albumTokens.forEach((token, index) => {
|
||||
@@ -149,7 +157,9 @@ export function ProofingProvider({
|
||||
setIsModalOpen,
|
||||
getProofingUrl,
|
||||
getFormattedList,
|
||||
getSelectedTokens,
|
||||
allowMailto,
|
||||
downloadArchiveUrl,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -10,6 +10,13 @@ export function ProofingModal() {
|
||||
const t = useDictionary();
|
||||
const proofing = useProofing();
|
||||
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.
|
||||
`proofing` can be null, hence the optional calls. */
|
||||
@@ -24,25 +31,37 @@ export function ProofingModal() {
|
||||
setIsModalOpen,
|
||||
getProofingUrl,
|
||||
getFormattedList,
|
||||
getSelectedTokens,
|
||||
clearFavorites,
|
||||
allowMailto,
|
||||
downloadArchiveUrl,
|
||||
} = proofing;
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const url = getProofingUrl();
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopiedState('link');
|
||||
setTimeout(() => setCopiedState('none'), 2000);
|
||||
});
|
||||
// What the archive would actually receive. Gating on this rather than on
|
||||
// `favorites.size` means a favourite left over from another album (they share
|
||||
// the provider's storage key when no `albumName` is passed) can never light up
|
||||
// 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);
|
||||
},
|
||||
() => setManualCopy(kind),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyList = () => {
|
||||
const list = getFormattedList();
|
||||
navigator.clipboard.writeText(list).then(() => {
|
||||
setCopiedState('list');
|
||||
setTimeout(() => setCopiedState('none'), 2000);
|
||||
});
|
||||
};
|
||||
const handleCopyLink = () => copy('link', getProofingUrl());
|
||||
|
||||
const handleCopyList = () => copy('list', getFormattedList());
|
||||
|
||||
const handleMailto = () => {
|
||||
const subject = encodeURIComponent(t.proofing.mailSubject(favorites.size));
|
||||
@@ -50,6 +69,31 @@ export function ProofingModal() {
|
||||
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 (
|
||||
<div
|
||||
className="proofing-modal-overlay"
|
||||
@@ -124,6 +168,47 @@ export function ProofingModal() {
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{downloadArchiveUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadSelection}
|
||||
disabled={selectedCount === 0}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 'var(--radius-sm, 6px)',
|
||||
background: 'var(--accent, #e60012)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
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}
|
||||
@@ -134,9 +219,13 @@ export function ProofingModal() {
|
||||
gap: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 'var(--radius-sm, 6px)',
|
||||
background: 'var(--accent, #e60012)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
...(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',
|
||||
}}
|
||||
@@ -180,6 +269,33 @@ export function ProofingModal() {
|
||||
)}
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
68
components/__tests__/ProofingModal.test.tsx
Normal file
68
components/__tests__/ProofingModal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
Add Block → Photo, where the picker fills the id in for you. Written by hand it
|
||||
is `` for a single photo — `:wide` or no suffix
|
||||
give the narrower widths — and `` for a
|
||||
side-by-side pair.
|
||||
give the narrower widths — `` for a
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -192,3 +192,11 @@ photos.example.com {
|
||||
> limits on the password endpoints. See
|
||||
> [Trusted Proxies](gallery-config.md#trusted-proxies) for the setting and a
|
||||
> 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.
|
||||
|
||||
@@ -203,6 +203,7 @@ albums:
|
||||
password: 'secure-password'
|
||||
heroImage: 'asset-uuid'
|
||||
sort: filename
|
||||
download: true
|
||||
```
|
||||
|
||||
| Key | Description |
|
||||
@@ -215,6 +216,7 @@ albums:
|
||||
| `assetOrder` | Pinned asset UUIDs for `sort: manual` |
|
||||
| `grid` | **Experimental.** Grid override for this album only |
|
||||
| `coverPosition` | **Experimental.** Focal point for the cover crop |
|
||||
| `download` | Offer originals for download — see [Originals download](#originals-download) |
|
||||
|
||||
### Per-album grid (experimental)
|
||||
|
||||
@@ -244,6 +246,29 @@ albums:
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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`:
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -137,9 +137,101 @@ real aspect ratios rather than forced equal:
|
||||

|
||||
```
|
||||
|
||||
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
|
||||

|
||||
```
|
||||
|
||||
Photos in an entry open in the same lightbox as the rest of the site, with
|
||||
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">
|
||||
<img src="screenshots/journal-entry.png" width="98%" alt="A rendered journal entry with heading, body text, a fullbleed photo and a quote" />
|
||||
</p>
|
||||
@@ -147,7 +239,10 @@ keyboard and swipe navigation.
|
||||
|
||||
> [!TIP]
|
||||
> 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
|
||||
|
||||
|
||||
95
e2e/proofing.spec.ts
Normal file
95
e2e/proofing.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
isAlbumReachable,
|
||||
withoutLockedAlbums,
|
||||
findSubpageBySlug,
|
||||
isHttpsRequest,
|
||||
} from '@/lib/auth';
|
||||
|
||||
describe('findSubpageBySlug', () => {
|
||||
@@ -286,3 +287,40 @@ describe('withoutLockedAlbums', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,7 @@ describe('dictionary parity', () => {
|
||||
'lightbox.iso',
|
||||
'lightbox.copyLinkShort',
|
||||
'lightbox.downloadShort',
|
||||
'common.website',
|
||||
'subpage.coverAria',
|
||||
]);
|
||||
|
||||
|
||||
141
lib/__tests__/journal-album.test.ts
Normal file
141
lib/__tests__/journal-album.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
86
lib/__tests__/journal-facts.test.ts
Normal file
86
lib/__tests__/journal-facts.test.ts
Normal 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: '<b>x</b>' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
70
lib/__tests__/journal-grid.test.ts
Normal file
70
lib/__tests__/journal-grid.test.ts
Normal 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('');
|
||||
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('');
|
||||
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);
|
||||
});
|
||||
});
|
||||
251
lib/__tests__/journal-map.test.ts
Normal file
251
lib/__tests__/journal-map.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseJournalMarkdown, serializeJournalMarkdown } from '../journal';
|
||||
import type { JournalBlock } from '../journal';
|
||||
|
||||
/**
|
||||
* 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("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']);
|
||||
});
|
||||
});
|
||||
|
||||
42
lib/__tests__/journal-templates.test.ts
Normal file
42
lib/__tests__/journal-templates.test.ts
Normal 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('');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
27
lib/auth.ts
27
lib/auth.ts
@@ -185,14 +185,37 @@ export function isProtected(key: string, type: ProtectedType = 'subpage'): boole
|
||||
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.
|
||||
* 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(
|
||||
key: string,
|
||||
password: string,
|
||||
type: ProtectedType = 'subpage',
|
||||
secure = true,
|
||||
): Promise<string | null> {
|
||||
const storedPassword = findPassword(key, type);
|
||||
if (!storedPassword) return null;
|
||||
@@ -237,9 +260,9 @@ export async function authenticate(
|
||||
|
||||
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,9 +7,13 @@ export {
|
||||
type JournalFrontmatter as EssayFrontmatter,
|
||||
type JournalBlock as EssayBlock,
|
||||
type ParsedJournal as ParsedEssay,
|
||||
type MapItem,
|
||||
isValidCoordinate,
|
||||
sanitizeHtml,
|
||||
renderInlineMarkdown,
|
||||
parseFrontmatter,
|
||||
parseJournalMarkdown as parseEssayMarkdown,
|
||||
serializeJournalMarkdown as serializeEssayMarkdown,
|
||||
collectAssetIds,
|
||||
mapBlockAssetIds,
|
||||
} from './journal';
|
||||
|
||||
@@ -27,6 +27,8 @@ export const de: Dictionary = {
|
||||
backTo: (label: string) => `Zurück zu ${label}`,
|
||||
backToGallery: 'Zurück zur Galerie',
|
||||
backToJournal: 'Zurück zum Journal',
|
||||
email: 'E-Mail',
|
||||
website: 'Website',
|
||||
home: 'Start',
|
||||
photos: (n: number) => plural(n, 'Foto', 'Fotos'),
|
||||
albums: (n: number) => plural(n, 'Album', 'Alben'),
|
||||
@@ -43,6 +45,7 @@ export const de: Dictionary = {
|
||||
entryNavAria: 'Journal-Navigation',
|
||||
loadingGallery: 'Galerie wird geladen',
|
||||
loadingPhotos: 'Fotos werden geladen',
|
||||
downloadAlbum: 'Album herunterladen',
|
||||
},
|
||||
|
||||
home: {
|
||||
@@ -61,6 +64,15 @@ export const de: Dictionary = {
|
||||
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: {
|
||||
switchToLight: 'Zum hellen Modus wechseln',
|
||||
switchToDark: 'Zum dunklen Modus wechseln',
|
||||
@@ -118,6 +130,7 @@ export const de: Dictionary = {
|
||||
|
||||
lightbox: {
|
||||
viewer: 'Bildansicht',
|
||||
openPhoto: (n: number) => `Foto ${n} ansehen`,
|
||||
close: 'Schließen',
|
||||
closeTitle: 'Schließen (Esc)',
|
||||
previous: 'Vorheriges Foto',
|
||||
@@ -178,7 +191,10 @@ export const de: Dictionary = {
|
||||
linkCopied: 'Link kopiert!',
|
||||
copyList: 'Textliste kopieren (#1, #2...)',
|
||||
listCopied: 'Liste kopiert!',
|
||||
copyManualLink: 'Diesen Link kopieren',
|
||||
copyManualList: 'Diese Liste kopieren',
|
||||
sendEmail: '✉️ E-Mail an den Fotografen',
|
||||
downloadSelected: 'Auswahl herunterladen (.zip)',
|
||||
clearSelection: 'Auswahl löschen',
|
||||
confirmClear: 'Die gesamte Auswahl löschen?',
|
||||
listEmpty: (albumName: string) => `${albumName}: Keine Fotos ausgewählt.`,
|
||||
|
||||
@@ -27,6 +27,9 @@ export const en = {
|
||||
backTo: (label: string) => `Back to ${label}`,
|
||||
backToGallery: 'Back to Gallery',
|
||||
backToJournal: 'Back to Journal',
|
||||
/** Footer contact links, read out by screen readers. */
|
||||
email: 'Email',
|
||||
website: 'Website',
|
||||
home: 'Home',
|
||||
photos: (n: number) => plural(n, 'photo', 'photos'),
|
||||
albums: (n: number) => plural(n, 'album', 'albums'),
|
||||
@@ -43,6 +46,7 @@ export const en = {
|
||||
entryNavAria: 'Journal entry navigation',
|
||||
loadingGallery: 'Loading gallery',
|
||||
loadingPhotos: 'Loading photos',
|
||||
downloadAlbum: 'Download album',
|
||||
},
|
||||
|
||||
home: {
|
||||
@@ -61,6 +65,15 @@ export const en = {
|
||||
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: {
|
||||
switchToLight: 'Switch to light mode',
|
||||
switchToDark: 'Switch to dark mode',
|
||||
@@ -118,6 +131,8 @@ export const en = {
|
||||
|
||||
lightbox: {
|
||||
viewer: 'Image viewer',
|
||||
/** A grid tile, which opens the viewer on that photo. */
|
||||
openPhoto: (n: number) => `View photo ${n}`,
|
||||
close: 'Close',
|
||||
closeTitle: 'Close (Esc)',
|
||||
previous: 'Previous photo',
|
||||
@@ -178,7 +193,11 @@ export const en = {
|
||||
linkCopied: 'Link Copied!',
|
||||
copyList: 'Copy Text List (#1, #2...)',
|
||||
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',
|
||||
downloadSelected: 'Download selected (.zip)',
|
||||
clearSelection: 'Clear Selection',
|
||||
confirmClear: 'Clear all selected favorites?',
|
||||
listEmpty: (albumName: string) => `${albumName}: No photos selected.`,
|
||||
|
||||
290
lib/journal.ts
290
lib/journal.ts
@@ -19,7 +19,53 @@ export type JournalBlock =
|
||||
| { type: 'paragraph'; html: string }
|
||||
| { type: 'quote'; text: string; author?: string }
|
||||
| { 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 {
|
||||
frontmatter: JournalFrontmatter;
|
||||
@@ -27,6 +73,51 @@ export interface ParsedJournal {
|
||||
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 {
|
||||
slug: string;
|
||||
filename: string;
|
||||
@@ -289,11 +380,6 @@ export function parseFrontmatter(content: string): {
|
||||
export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
const { frontmatter, body } = parseFrontmatter(rawContent);
|
||||
const blocks: JournalBlock[] = [];
|
||||
const referencedAssetIds = new Set<string>();
|
||||
|
||||
if (frontmatter.coverAssetId) {
|
||||
referencedAssetIds.add(frontmatter.coverAssetId);
|
||||
}
|
||||
|
||||
// Split body into paragraph chunks separated by blank lines
|
||||
const chunks = body
|
||||
@@ -302,6 +388,106 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
.filter(Boolean);
|
||||
|
||||
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)
|
||||
// `[ \t]+` rather than `\s+`: next to `(.+)` the two overlap, and the
|
||||
// engine has to try every split of the whitespace run before failing.
|
||||
@@ -333,23 +519,30 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Image syntax:  or 
|
||||
const imgMatch = chunk.match(/^!\[([^\]]+)\]\(([^)]*)\)$/);
|
||||
// 3. Image syntax: ,  for a
|
||||
// pair,  for a grid
|
||||
//
|
||||
// The bracket group allows zero characters (`*`, not `+`) so a template's
|
||||
// unfilled placeholder — `assetId: ''`, serialized as `` or
|
||||
// `` — 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) {
|
||||
const rawTarget = imgMatch[1].trim();
|
||||
const caption = imgMatch[2].trim() ? renderInlineMarkdown(imgMatch[2].trim()) : undefined;
|
||||
|
||||
// Side-by-side pair: 
|
||||
// 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(',')) {
|
||||
const parts = rawTarget.split(',').map((s) => s.trim());
|
||||
if (parts.length >= 2) {
|
||||
const id1 = parts[0];
|
||||
const id2 = parts[1];
|
||||
referencedAssetIds.add(id1);
|
||||
referencedAssetIds.add(id2);
|
||||
if (parts.length >= 3) {
|
||||
blocks.push({ type: 'photo-grid', assetIds: parts, caption });
|
||||
continue;
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
blocks.push({
|
||||
type: 'photo-pair',
|
||||
assetIds: [id1, id2],
|
||||
assetIds: [parts[0], parts[1]],
|
||||
caption,
|
||||
});
|
||||
continue;
|
||||
@@ -368,7 +561,6 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
else if (normLayout === 'wide') layout = 'wide';
|
||||
}
|
||||
|
||||
referencedAssetIds.add(assetId);
|
||||
blocks.push({
|
||||
type: 'photo',
|
||||
assetId,
|
||||
@@ -385,11 +577,15 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter,
|
||||
blocks,
|
||||
referencedAssetIds: Array.from(referencedAssetIds),
|
||||
};
|
||||
// The cover comes first, as before; collectAssetIds() skips '' placeholders.
|
||||
const referencedAssetIds = Array.from(
|
||||
new Set([
|
||||
...(frontmatter.coverAssetId ? [frontmatter.coverAssetId] : []),
|
||||
...collectAssetIds(blocks),
|
||||
]),
|
||||
);
|
||||
|
||||
return { frontmatter, blocks, referencedAssetIds };
|
||||
}
|
||||
|
||||
/** 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})`);
|
||||
break;
|
||||
}
|
||||
case 'photo-grid': {
|
||||
const caption = block.caption
|
||||
? inlineHtmlToMarkdown(block.caption.replace(/[\r\n]+/g, ' '))
|
||||
: '';
|
||||
lines.push(``);
|
||||
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('');
|
||||
}
|
||||
@@ -460,7 +702,11 @@ export function serializeJournalMarkdown(journal: ParsedJournal): string {
|
||||
|
||||
/** Calculate approximate word count and reading time */
|
||||
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 minutes = Math.max(1, Math.round(words / 200));
|
||||
return { words, minutes };
|
||||
|
||||
107
lib/journalAlbum.ts
Normal file
107
lib/journalAlbum.ts
Normal 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
117
lib/journalMap.ts
Normal 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
192
lib/journalTemplates.ts
Normal 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.]'),
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -66,6 +66,11 @@ export function quantiseCoordinate(value: number, level: LocationPrecision): num
|
||||
if (level === 'hidden') return 0;
|
||||
const { step, decimals } = GRID[level];
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
12
lib/urls.ts
12
lib/urls.ts
@@ -107,6 +107,18 @@ export function downloadUrl(albumId: string, assetId: string): string {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -18,8 +18,23 @@ const securityHeaders = [
|
||||
{ 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 = {
|
||||
output: 'standalone',
|
||||
allowedDevOrigins,
|
||||
images: {
|
||||
loader: 'custom',
|
||||
loaderFile: './lib/immichLoader.ts',
|
||||
|
||||
922
package-lock.json
generated
922
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-folio",
|
||||
"version": "0.16.0",
|
||||
"version": "0.17.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -21,6 +21,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"archiver": "^7.0.1",
|
||||
"js-yaml": "^4.3.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "16.3.5",
|
||||
@@ -32,6 +33,7 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/archiver": "^6.0.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^20",
|
||||
@@ -44,7 +46,7 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier": "^3.9.8",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
|
||||
1050
pnpm-lock.yaml
generated
1050
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user