Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
544 lines
19 KiB
TypeScript
544 lines
19 KiB
TypeScript
'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 } 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: () => <div className="essay-map" />,
|
|
});
|
|
|
|
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<number | null>(null);
|
|
|
|
// Map assetId / index to PhotoItem and its index in `assets`
|
|
const assetMap = useMemo(() => {
|
|
const map = new Map<string, { item: PhotoItem; index: number }>();
|
|
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 (
|
|
<div
|
|
key={key}
|
|
className="essay-image-wrapper photo-grid__item"
|
|
onClick={() => openPhoto(item)}
|
|
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': {
|
|
const Tag = `h${Math.min(block.level + 1, 4)}` as keyof React.JSX.IntrinsicElements;
|
|
return (
|
|
<Tag key={idx} className={`essay-heading essay-heading--${block.level}`}>
|
|
{block.text}
|
|
</Tag>
|
|
);
|
|
}
|
|
|
|
case 'paragraph':
|
|
return (
|
|
<div key={idx} className="essay-prose" dangerouslySetInnerHTML={{ __html: block.html }} />
|
|
);
|
|
|
|
case 'quote':
|
|
return (
|
|
<blockquote key={idx} className="essay-quote">
|
|
<div dangerouslySetInnerHTML={{ __html: block.text }} />
|
|
{block.author && (
|
|
<span
|
|
className="essay-quote__author"
|
|
// The attribution keeps its Markdown and is rendered here, like
|
|
// the quote body, so `[link](…)` becomes an anchor (#559).
|
|
// renderInlineMarkdown escapes everything it does not emit.
|
|
dangerouslySetInnerHTML={{ __html: `— ${renderInlineMarkdown(block.author)}` }}
|
|
/>
|
|
)}
|
|
</blockquote>
|
|
);
|
|
|
|
case 'photo': {
|
|
const resolved = assetMap.get(block.assetId);
|
|
if (!resolved) return null;
|
|
const { item } = resolved;
|
|
const isFav = proofing ? proofing.isFavorite(item.id) : false;
|
|
|
|
return (
|
|
<FadeIn key={idx}>
|
|
<figure className={`essay-figure essay-figure--${block.layout}`}>
|
|
<div
|
|
className="essay-image-wrapper photo-grid__item"
|
|
onClick={() => openPhoto(item)}
|
|
style={{
|
|
...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
|
|
...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}),
|
|
}}
|
|
>
|
|
<Image
|
|
src={item.previewUrl || item.thumbUrl}
|
|
alt={block.caption || ''}
|
|
fill
|
|
sizes={
|
|
block.layout === 'fullbleed'
|
|
? '100vw'
|
|
: block.layout === 'wide'
|
|
? '(max-width: 1100px) 100vw, 1100px'
|
|
: '(max-width: 768px) 100vw, 680px'
|
|
}
|
|
loading={idx < 4 ? 'eager' : '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>
|
|
{block.caption && (
|
|
<figcaption
|
|
className="essay-figcaption"
|
|
dangerouslySetInnerHTML={{ __html: block.caption }}
|
|
/>
|
|
)}
|
|
</figure>
|
|
</FadeIn>
|
|
);
|
|
}
|
|
|
|
case 'photo-pair': {
|
|
const res1 = assetMap.get(block.assetIds[0]);
|
|
const res2 = assetMap.get(block.assetIds[1]);
|
|
if (!res1 || !res2) return null;
|
|
|
|
return (
|
|
<FadeIn key={idx}>
|
|
<div className="essay-figure essay-figure--wide">
|
|
<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
|
|
className="essay-figcaption"
|
|
dangerouslySetInnerHTML={{ __html: block.caption }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</FadeIn>
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
};
|
|
|
|
// The story's photos that are selected, not the whole stored set (see PhotoGrid).
|
|
const selectedCount = proofing ? proofing.getSelectedTokens().length : 0;
|
|
|
|
return (
|
|
<div className="essay-container">
|
|
<header className="essay-header">
|
|
{displayTitle && <h1 className="essay-header__title">{displayTitle}</h1>}
|
|
{displaySubtitle && <p className="essay-header__subtitle">{displaySubtitle}</p>}
|
|
{(essay.frontmatter.author || essay.frontmatter.date) && (
|
|
<div className="essay-header__meta">
|
|
{[essay.frontmatter.author, formatJournalDate(essay.frontmatter.date, t.dateLocale)]
|
|
.filter(Boolean)
|
|
.join(' • ')}
|
|
</div>
|
|
)}
|
|
</header>
|
|
|
|
{coverAsset && (
|
|
<figure className="essay-figure essay-figure--wide" style={{ marginBottom: '4rem' }}>
|
|
<div
|
|
className="essay-image-wrapper photo-grid__item"
|
|
onClick={() => openPhoto(coverAsset.item)}
|
|
style={{
|
|
...(coverAsset.item.dominantColor
|
|
? { backgroundColor: coverAsset.item.dominantColor }
|
|
: {}),
|
|
...(coverAsset.item.aspectRatio
|
|
? { aspectRatio: `${coverAsset.item.aspectRatio}` }
|
|
: {}),
|
|
}}
|
|
>
|
|
<Image
|
|
src={coverAsset.item.previewUrl || coverAsset.item.thumbUrl}
|
|
alt=""
|
|
fill
|
|
priority
|
|
sizes="(max-width: 1100px) 100vw, 1100px"
|
|
{...(coverAsset.item.blurDataURL
|
|
? { placeholder: 'blur' as const, blurDataURL: coverAsset.item.blurDataURL }
|
|
: {})}
|
|
/>
|
|
</div>
|
|
</figure>
|
|
)}
|
|
|
|
<div>{essay.blocks.map(renderBlock)}</div>
|
|
|
|
{proofing && selectedCount > 0 && (
|
|
<div
|
|
className="proofing-sticky-bar"
|
|
style={{
|
|
position: 'fixed',
|
|
bottom: '24px',
|
|
right: '24px',
|
|
zIndex: 990,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '12px',
|
|
padding: '8px 16px',
|
|
borderRadius: '30px',
|
|
background: 'var(--bg-card, #1e1e1e)',
|
|
color: 'var(--text-primary, #ffffff)',
|
|
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
|
|
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
|
backdropFilter: 'blur(8px)',
|
|
}}
|
|
>
|
|
{/* A count, not the album grid's filter toggle: a story's layout is
|
|
authored, so there is nothing for "show favourites" to narrow. */}
|
|
<span style={{ fontSize: '0.85rem', fontWeight: 500, padding: '6px 4px' }}>
|
|
{t.proofing.selected(selectedCount)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => proofing.setIsModalOpen(true)}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
color: 'inherit',
|
|
fontSize: '0.85rem',
|
|
fontWeight: 500,
|
|
cursor: 'pointer',
|
|
textDecoration: 'underline',
|
|
}}
|
|
>
|
|
{t.proofing.shareExport}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{proofingEnabled && <ProofingModal />}
|
|
|
|
{lightboxIndex !== null && (
|
|
<Lightbox
|
|
assets={sequence}
|
|
currentIndex={lightboxIndex}
|
|
onClose={() => 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}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 <EssayViewContent {...props} />;
|
|
}
|
|
|
|
return (
|
|
<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}
|
|
encodedMailto={props.encodedMailto}
|
|
>
|
|
<EssayViewContent {...props} />
|
|
</ProofingProvider>
|
|
);
|
|
}
|