release: v0.17.0 (#677)

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

View File

@@ -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} />}
</>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest';
import type { JournalBlock } from '@/lib/journal';
import { serializeJournalMarkdown, parseJournalMarkdown } from '@/lib/journal';
import { createBlock, moveBlock, isLegacyAssetRef } from '../components/journal/blockOps';
import {
clampSplit,
splitFromPointer,
splitAfterKey,
parseStoredSplit,
SPLIT_MIN,
SPLIT_MAX,
SPLIT_DEFAULT,
} from '../components/journal/splitPane';
/**
* The logic that came out of the journal editor when it was split up (#555).
* Inside the 2000-line component none of it could be reached by a test.
*/
const TYPES: JournalBlock['type'][] = [
'heading',
'paragraph',
'quote',
'photo',
'photo-pair',
'photo-grid',
'facts',
'map',
'album',
];
describe('createBlock', () => {
it.each(TYPES)('creates a %s block', (type) => {
expect(createBlock(type).type).toBe(type);
});
it('hands out a fresh object each time', () => {
const a = createBlock('facts');
const b = createBlock('facts');
expect(a).not.toBe(b);
if (a.type === 'facts' && b.type === 'facts') expect(a.items).not.toBe(b.items);
});
/**
* A new block is saved as soon as the author types anywhere else; one that
* does not survive the markdown round trip would vanish on the next load.
*/
it.each(TYPES.filter((t) => t !== 'map' && t !== 'album'))(
'a new %s block survives a save and reload',
(type) => {
const md = serializeJournalMarkdown({
frontmatter: { title: 'T' },
blocks: [createBlock(type)],
referencedAssetIds: [],
});
expect(parseJournalMarkdown(md).blocks.map((b) => b.type)).toEqual([type]);
},
);
});
describe('moveBlock', () => {
const blocks = (['paragraph', 'heading', 'quote'] as const).map(createBlock);
it('moves a block up and down', () => {
expect(moveBlock(blocks, 1, 'up')?.map((b) => b.type)).toEqual([
'heading',
'paragraph',
'quote',
]);
expect(moveBlock(blocks, 1, 'down')?.map((b) => b.type)).toEqual([
'paragraph',
'quote',
'heading',
]);
});
it('refuses to move past either end', () => {
expect(moveBlock(blocks, 0, 'up')).toBeNull();
expect(moveBlock(blocks, 2, 'down')).toBeNull();
});
it('leaves the original list alone', () => {
moveBlock(blocks, 1, 'up');
expect(blocks.map((b) => b.type)).toEqual(['paragraph', 'heading', 'quote']);
});
});
describe('isLegacyAssetRef', () => {
it('flags positional references, not UUIDs or empty slots', () => {
expect(isLegacyAssetRef('1')).toBe(true);
expect(isLegacyAssetRef('')).toBe(false);
expect(isLegacyAssetRef('0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0')).toBe(false);
});
});
describe('split pane', () => {
it('clamps to its bounds', () => {
expect(clampSplit(0)).toBe(SPLIT_MIN);
expect(clampSplit(100)).toBe(SPLIT_MAX);
expect(clampSplit(50)).toBe(50);
});
it('turns a pointer position into a percentage of the view', () => {
expect(splitFromPointer(600, 100, 1000)).toBe(50);
expect(splitFromPointer(0, 100, 1000)).toBe(SPLIT_MIN);
// A collapsed view has no meaningful position.
expect(splitFromPointer(600, 100, 0)).toBeNull();
});
it('nudges with the arrows, further with Shift, and resets with Home', () => {
expect(splitAfterKey(50, 'ArrowLeft', false)).toBe(48);
expect(splitAfterKey(50, 'ArrowRight', true)).toBe(60);
expect(splitAfterKey(SPLIT_MIN, 'ArrowLeft', true)).toBe(SPLIT_MIN);
expect(splitAfterKey(SPLIT_MAX, 'ArrowRight', false)).toBe(SPLIT_MAX);
expect(splitAfterKey(30, 'Home', false)).toBe(SPLIT_DEFAULT);
// Anything else is not the divider's key — Tab must still move focus.
expect(splitAfterKey(50, 'Tab', false)).toBeNull();
});
it('only restores a stored width inside the bounds', () => {
expect(parseStoredSplit('40')).toBe(40);
expect(parseStoredSplit(null)).toBeNull();
expect(parseStoredSplit('90')).toBeNull();
expect(parseStoredSplit('abc')).toBeNull();
});
});

View File

@@ -0,0 +1,157 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, vi } from 'vitest';
import { renderHook, act, render, screen, fireEvent, cleanup } from '@testing-library/react';
import { useDraft, readDraft } from '../components/useDraft';
import DraftNotice from '../components/DraftNotice';
/**
* #592: leaving the page builder or the journal editor inside the admin — a tab
* link, back, Reload, Logout — unmounted it and threw the edits away. The draft
* is what brings them back, so these tests walk the round trip an operator
* makes: edit, leave (unmount), come back (a fresh hook), load.
*/
afterEach(() => {
cleanup();
window.sessionStorage.clear();
vi.restoreAllMocks();
});
type Props = { value: string; dirty: boolean };
function mount(key = 'k', initial: Props = { value: '', dirty: false }) {
return renderHook(({ value, dirty }: Props) => useDraft(key, value, dirty), {
initialProps: initial,
});
}
describe('useDraft', () => {
it('writes nothing before the server state has loaded', () => {
// The editor mounts with an empty state; that must not become a draft.
mount('k', { value: 'empty', dirty: true });
expect(readDraft('k')).toBeNull();
});
it('brings edits back after leaving and returning', () => {
const first = mount();
act(() => void first.result.current.load('server-v1'));
first.rerender({ value: 'my edit', dirty: true });
first.unmount();
const second = mount();
let restored: string | null = null;
act(() => {
restored = second.result.current.load('server-v1');
});
expect(restored).toBe('my edit');
expect(second.result.current.status).toBe('restored');
});
it('holds a draft back when the server changed since', () => {
const first = mount();
act(() => void first.result.current.load('server-v1'));
first.rerender({ value: 'my edit', dirty: true });
first.unmount();
const second = mount();
let restored: string | null = 'unset';
act(() => {
restored = second.result.current.load('server-v2');
});
// Applying it silently would overwrite the other save.
expect(restored).toBeNull();
expect(second.result.current.status).toBe('conflict');
let taken: string | null = null;
act(() => {
taken = second.result.current.takeConflicting();
});
expect(taken).toBe('my edit');
expect(second.result.current.status).toBe('restored');
});
it('forgets the draft once saved, and bases new edits on the saved state', () => {
const first = mount();
act(() => void first.result.current.load('server-v1'));
first.rerender({ value: 'v2', dirty: true });
act(() => first.result.current.saved('server-v2'));
first.rerender({ value: 'v2', dirty: false });
expect(readDraft('k')).toBeNull();
// Edits after the save must restore against the saved version, not the
// one the editor first loaded.
first.rerender({ value: 'v3 edit', dirty: true });
first.unmount();
const second = mount();
let restored: string | null = null;
act(() => {
restored = second.result.current.load('server-v2');
});
expect(restored).toBe('v3 edit');
});
it('drops the draft when the edits are undone by a discard', () => {
const first = mount();
act(() => void first.result.current.load('server-v1'));
first.rerender({ value: 'my edit', dirty: true });
act(() => first.result.current.discard());
first.rerender({ value: 'server-v1', dirty: false });
expect(readDraft('k')).toBeNull();
expect(first.result.current.status).toBe('none');
});
it('keeps editors apart by key', () => {
const a = mount('journal-a');
act(() => void a.result.current.load('a'));
a.rerender({ value: 'edit in a', dirty: true });
const b = mount('journal-b');
let restored: string | null = 'unset';
act(() => {
restored = b.result.current.load('a');
});
expect(restored).toBeNull();
});
it('degrades to no draft when storage is unavailable', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('SecurityError');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError');
});
const { result, rerender } = mount();
let restored: string | null = 'unset';
act(() => {
restored = result.current.load('server-v1');
});
expect(restored).toBeNull();
expect(() => rerender({ value: 'edit', dirty: true })).not.toThrow();
});
});
describe('DraftNotice', () => {
const handlers = () => ({ onDiscard: vi.fn(), onRestore: vi.fn(), onDismiss: vi.fn() });
it('says nothing without a draft', () => {
const { container } = render(<DraftNotice status="none" subject="entry" {...handlers()} />);
expect(container.firstChild).toBeNull();
});
it('says a restored draft is still unsaved and offers to discard it', () => {
const h = handlers();
render(<DraftNotice status="restored" subject="entry" {...h} />);
expect(screen.getByText(/were restored\. They are not saved yet/)).toBeTruthy();
fireEvent.click(screen.getByText('Discard changes'));
expect(h.onDiscard).toHaveBeenCalled();
});
it('asks before restoring over a newer save', () => {
const h = handlers();
render(<DraftNotice status="conflict" subject="page structure" {...h} />);
fireEvent.click(screen.getByText('Restore my changes'));
expect(h.onRestore).toHaveBeenCalled();
fireEvent.click(screen.getByText('Discard them'));
expect(h.onDiscard).toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,17 @@
'use client';
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 {

View File

@@ -0,0 +1,61 @@
'use client';
import type { DraftStatus } from './useDraft';
interface DraftNoticeProps {
status: DraftStatus;
/** What the draft belongs to, e.g. "page structure" or "entry". */
subject: string;
onDiscard: () => void;
onRestore: () => void;
onDismiss: () => void;
}
/**
* Tells the operator that unsaved edits came back — or could not come back on
* their own (#592). A restored draft must never look like the saved state: it
* is still unsaved, and the save bar says so, but without this line the edits
* would simply be there with no hint of where they came from.
*/
export default function DraftNotice({
status,
subject,
onDiscard,
onRestore,
onDismiss,
}: DraftNoticeProps) {
if (status === 'none') return null;
return (
<div className="admin-notice draft-notice" role="status">
{status === 'restored' ? (
<>
<span>Your unsaved changes to the {subject} were restored. They are not saved yet.</span>
<span className="draft-notice-actions">
<button type="button" className="admin-btn admin-btn-xs" onClick={onDiscard}>
Discard changes
</button>
<button type="button" className="admin-btn admin-btn-xs" onClick={onDismiss}>
OK
</button>
</span>
</>
) : (
<>
<span>
You have unsaved changes to the {subject} from earlier, but it was saved elsewhere
since. Restoring them replaces that version when you save.
</span>
<span className="draft-notice-actions">
<button type="button" className="admin-btn admin-btn-xs" onClick={onRestore}>
Restore my changes
</button>
<button type="button" className="admin-btn admin-btn-xs" onClick={onDiscard}>
Discard them
</button>
</span>
</>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -395,6 +395,188 @@
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
}
/* ── 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 {

View File

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

View File

@@ -100,6 +100,43 @@ export default function AlbumDrawer({
/>
</div>
</div>
<div 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">

View File

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

View File

@@ -1,4 +1,5 @@
import { JournalStudio } from '../../components/JournalStudio';
import { JournalStudio } from '../../components/journal/JournalStudio';
import { getConfig } from '@/lib/config';
export default async function AdminJournalEntryPage({
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} />;
}

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -9,6 +9,7 @@ import {
MAX_PASSWORD_LENGTH,
} 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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

View File

@@ -1,4 +1,6 @@
/* ── Map Page ──────────────────────────────────────────────── */
/* 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;
}

View File

@@ -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"