'use client'; import { useState, useMemo } from 'react'; import Image from 'next/image'; import dynamic from 'next/dynamic'; import { Lightbox, type LightboxWatermark } from '@/components/Lightbox'; import { useProofing } from '@/components/useProofing'; import { ProofingProvider, ProofingModal } from '@/components/ProofingLazy'; import { FadeIn } from '@/components/FadeIn'; import { escapeHtml } from '@/lib/escapeHtml'; import type { ParsedEssay, EssayBlock } from '@/lib/essay'; import { renderInlineMarkdown } from '@/lib/essay'; import { essayPhotoSequence } from '@/lib/essaySequence'; import type { PhotoItem } from './PhotoGrid'; import './essay.css'; import { useDictionary } from '@/components/I18nProvider'; import { formatJournalDate, journalDateTime } from '@/lib/journalDate'; /** * The map block is the only thing in an essay that needs Leaflet, and most * essays have none. Imported statically, LeafletMap put its wrapper and * Leaflet's stylesheet (render-blocking CSS) on every album, subpage and * journal page. The map only ever drew on the client — Leaflet itself was * already imported inside an effect — so the server renders the same empty * container it rendered before, and the wrapper arrives with the map. */ const LeafletMap = dynamic(() => import('@/components/LeafletMap').then((m) => m.LeafletMap), { ssr: false, loading: () =>
, }); interface EssayViewProps { essay: ParsedEssay; assets: PhotoItem[]; title?: string; subtitle?: string; watermark?: LightboxWatermark; /** * Client proofing — favorite hearts, the selection bar and the send-off modal. * Off by default: proofing is a delivery workflow for album handovers, and a * published story is not one. Album views opt in. */ proofing?: boolean; /** Offer the "send by email" button in the proofing modal. */ allowMailto?: boolean; /** `encodeEmail()` of the proofing recipient, when one is configured (#736). */ encodedMailto?: string; } function EssayViewContent({ essay, assets, title, subtitle, watermark, proofing: proofingEnabled = false, }: EssayViewProps) { const t = useDictionary(); const proofing = useProofing(); const [lightboxIndex, setLightboxIndex] = useState(null); // Map assetId / index to PhotoItem and its index in `assets` const assetMap = useMemo(() => { const map = new Map(); assets.forEach((item, index) => { map.set(item.id, { item, index }); // Also map by 1-based index string ("1", "2") for convenience map.set(`${index + 1}`, { item, index }); }); return map; }, [assets]); const displayTitle = title || essay.frontmatter.title; const displaySubtitle = subtitle || essay.frontmatter.subtitle; // Cover photo if specified const coverAsset = essay.frontmatter.coverAssetId ? assetMap.get(essay.frontmatter.coverAssetId) : undefined; // The lightbox walks only the photos this essay shows, in reading order — // not every photo of the subpage's albums (`assets`). const sequence = useMemo( () => essayPhotoSequence( essay.blocks, essay.frontmatter.coverAssetId, (key) => assetMap.get(key)?.item, ), [essay.blocks, essay.frontmatter.coverAssetId, assetMap], ); const openPhoto = (item: PhotoItem) => { const at = sequence.findIndex((p) => p.id === item.id); if (at !== -1) setLightboxIndex(at); }; /** * 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 }: { item: PhotoItem }, key: number) => { const isFav = proofing ? proofing.isFavorite(item.id) : false; return (
openPhoto(item)} style={{ ...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}), flexGrow: item.aspectRatio ?? 1.5, aspectRatio: `${item.aspectRatio ?? 1.5}`, }} > {proofing && ( )}
); }; const renderBlock = (block: EssayBlock, idx: number) => { switch (block.type) { case 'heading': { const Tag = `h${Math.min(block.level + 1, 4)}` as keyof React.JSX.IntrinsicElements; return ( {block.text} ); } case 'paragraph': return (
); case 'quote': return (
{block.author && ( )}
); case 'photo': { const resolved = assetMap.get(block.assetId); if (!resolved) return null; const { item } = resolved; const isFav = proofing ? proofing.isFavorite(item.id) : false; return (
openPhoto(item)} style={{ ...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}), ...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}), }} > {block.caption {proofing && ( )}
{block.caption && (
)}
); } case 'photo-pair': { const res1 = assetMap.get(block.assetIds[0]); const res2 = assetMap.get(block.assetIds[1]); if (!res1 || !res2) return null; return (
{[res1, res2].map(renderTile)}
{block.caption && (
)}
); } case 'photo-grid': { // Unresolved ids drop out individually; a grid with fewer than two // photos left is not a grid any more and renders as nothing, like a // pair with a missing half. const resolved = block.assetIds.flatMap((id) => { const r = assetMap.get(id); return r ? [r] : []; }); if (resolved.length < 2) return null; // Rows of three, each justified like a pair: one shared height, widths // from the real ratios, nothing cropped. const rows: (typeof resolved)[] = []; for (let i = 0; i < resolved.length; i += 3) rows.push(resolved.slice(i, i + 3)); return (
{rows.map((row, rIdx) => (
{row.map(renderTile)}
))}
{block.caption && (
)}
); } case 'facts': if (block.items.length === 0) return null; return (
{block.items.map((item, i) => (
{item.label}
))}
); case 'map': { // The server page attaches `pins` (typed points as typed, photo pins // quantised). Without them — the studio preview — the typed points // still show, so an author sees the map take shape while writing; // photo pins appear on the live page. No pins at all: no map. const pins = block.pins ?? block.items.flatMap((item) => item.kind === 'point' ? [ item.label ? { lat: item.lat, lng: item.lng, label: item.label } : { lat: item.lat, lng: item.lng }, ] : [], ); if (pins.length === 0) return null; return (
({ lat: pin.lat, lng: pin.lng, html: `
${i + 1}
`, ...(pin.label ? { popupHtml: `

${escapeHtml(pin.label)}

`, } : {}), }))} /> {block.caption && (
)}
); } default: return null; } }; // The story's photos that are selected, not the whole stored set (see PhotoGrid). const selectedCount = proofing ? proofing.getSelectedTokens().length : 0; const dateText = formatJournalDate(essay.frontmatter.date, t.dateLocale); const dateTime = journalDateTime(essay.frontmatter.date); return (
{displayTitle &&

{displayTitle}

} {displaySubtitle &&

{displaySubtitle}

} {(essay.frontmatter.author || dateText) && (
{essay.frontmatter.author} {essay.frontmatter.author && dateText && ' • '} {dateText && (dateTime ? : dateText)}
)}
{coverAsset && (
openPhoto(coverAsset.item)} style={{ ...(coverAsset.item.dominantColor ? { backgroundColor: coverAsset.item.dominantColor } : {}), ...(coverAsset.item.aspectRatio ? { aspectRatio: `${coverAsset.item.aspectRatio}` } : {}), }} >
)}
{essay.blocks.map(renderBlock)}
{proofing && selectedCount > 0 && (
{/* A count, not the album grid's filter toggle: a story's layout is authored, so there is nothing for "show favourites" to narrow. */} {t.proofing.selected(selectedCount)}
)} {proofingEnabled && } {lightboxIndex !== null && ( setLightboxIndex(null)} onNext={() => setLightboxIndex((prev) => (prev !== null ? (prev + 1) % sequence.length : null)) } onPrev={() => setLightboxIndex((prev) => prev !== null ? (prev - 1 + sequence.length) % sequence.length : null, ) } watermark={watermark} showExifToggle={false} /> )}
); } export function EssayView(props: EssayViewProps) { const albumTokens = useMemo(() => props.assets.map((a) => a.id), [props.assets]); // Without the provider useProofing() returns null, and every proofing control // (hearts in the grid and in the lightbox, selection bar) drops out on its own. if (!props.proofing) { return ; } return ( ); }