'use client'; import { useState, useCallback } from 'react'; import { parseEssayMarkdown, serializeEssayMarkdown, collectAssetIds, isValidCoordinate, type ParsedEssay, type EssayBlock, type MapItem, } from '@/lib/essay'; import { arrayMove } from '@dnd-kit/sortable'; import { SortableBlockList, SortableBlockCard } from './SortableBlocks'; import { IconCamera, IconChevronUp, IconChevronDown, IconTrash, IconQuote, IconFileText, IconSparkles, IconArrowLeftRight, IconGrid, IconColumns, IconMap, IconFolder, IconPlus, IconX, } from './Icons'; import { BlockBadge } from './BlockBadge'; import { InlineMarkdownField } from './InlineMarkdownField'; interface EssayBlockEditorProps { markdown: string; onChange: (newMarkdown: string) => void; onSelectPhoto?: (callback: (assetId: string) => void) => void; } export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlockEditorProps) { const [essay, setEssay] = useState(() => parseEssayMarkdown(markdown || '# Title\n\nWrite your story here...'), ); // Sync internal state when the prop changes from outside (e.g. initial load). // Adjusting during render rather than in an effect: an effect would commit the // stale essay first and re-render, which flashes the previous story's blocks. const [lastMarkdown, setLastMarkdown] = useState(markdown); if (markdown !== lastMarkdown) { setLastMarkdown(markdown); if (markdown && markdown !== serializeEssayMarkdown(essay)) { setEssay(parseEssayMarkdown(markdown)); } } const updateBlocks = useCallback( (newBlocks: EssayBlock[]) => { const updatedEssay: ParsedEssay = { ...essay, blocks: newBlocks, referencedAssetIds: collectAssetIds(newBlocks), }; setEssay(updatedEssay); onChange(serializeEssayMarkdown(updatedEssay)); }, [essay, onChange], ); // Block manipulation helpers const handleMoveBlock = (index: number, direction: 'up' | 'down') => { const targetIdx = direction === 'up' ? index - 1 : index + 1; if (targetIdx < 0 || targetIdx >= essay.blocks.length) return; const newBlocks = [...essay.blocks]; const [moved] = newBlocks.splice(index, 1); newBlocks.splice(targetIdx, 0, moved); updateBlocks(newBlocks); }; const handleReorderBlock = (from: number, to: number) => { updateBlocks(arrayMove(essay.blocks, from, to)); }; const handleDeleteBlock = (index: number) => { const newBlocks = essay.blocks.filter((_, i) => i !== index); updateBlocks(newBlocks); }; const handleUpdateBlock = (index: number, updated: EssayBlock) => { const newBlocks = [...essay.blocks]; newBlocks[index] = updated; updateBlocks(newBlocks); }; const handleAddBlock = (type: EssayBlock['type']) => { let newBlock: EssayBlock; switch (type) { case 'heading': newBlock = { type: 'heading', level: 2, text: 'New Section Heading' }; break; case 'paragraph': newBlock = { type: 'paragraph', html: 'Enter text paragraph here...' }; break; case 'quote': newBlock = { type: 'quote', text: 'Enter quote text here...', author: '' }; break; case 'photo': newBlock = { type: 'photo', assetId: '', caption: '', layout: 'contained', }; break; case 'photo-pair': newBlock = { type: 'photo-pair', assetIds: ['', ''], caption: '', }; break; case 'photo-grid': newBlock = { type: 'photo-grid', assetIds: ['', '', ''], caption: '' }; break; case 'facts': newBlock = { type: 'facts', items: [ { label: '', value: '' }, { label: '', value: '' }, ], }; break; case 'map': newBlock = { type: 'map', caption: '', line: true, items: [] }; break; case 'album': newBlock = { type: 'album', albumId: '', layout: 'grid' }; break; } updateBlocks([...essay.blocks, newBlock]); }; return (
{/* Sticky Dark Glass Toolbar */}
Add Story Block:
{essay.blocks.length === 0 && (

No story blocks yet. Use the buttons above to build your photo essay!

)} {/* Render Blocks */} {essay.blocks.map((block, idx) => ( } actions={
} > {/* Block Form Fields */} {/* 1. HEADING BLOCK */} {block.type === 'heading' && (
handleUpdateBlock(idx, { ...block, text: e.target.value })} placeholder="Section heading..." style={{ flex: 1, fontWeight: block.level === 1 ? 700 : block.level === 2 ? 600 : 500, }} />
)} {/* 2. PARAGRAPH BLOCK The field shows the markdown source and writes the rendered HTML back, as the journal editor does (InlineMarkdownField). An older version stripped the tags for display and wrote the stripped text straight back into `html`, destroying italics and links; the one after that showed the raw HTML. */} {block.type === 'paragraph' && ( handleUpdateBlock(idx, { ...block, html })} placeholder="Write text paragraph... (**bold**, *italic* markdown supported)" /> )} {/* 3. PULLQUOTE BLOCK */} {block.type === 'quote' && (
handleUpdateBlock(idx, { ...block, text })} placeholder="Quote text..." style={{ fontStyle: 'italic', fontFamily: 'serif', fontSize: '1rem' }} /> handleUpdateBlock(idx, { ...block, author: e.target.value || undefined }) } placeholder="— Author / Attribution (optional)" />
)} {/* 4. PHOTO BLOCK */} {block.type === 'photo' && (
{block.assetId ? ( { (e.target as HTMLElement).style.display = 'none'; }} /> ) : (
No Photo
)}
{onSelectPhoto && ( )}
handleUpdateBlock(idx, { ...block, assetId: e.target.value }) } placeholder="Immich Asset UUID..." style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }} />
handleUpdateBlock(idx, { ...block, caption: caption || undefined }) } placeholder="Photo caption (optional)" />
)} {/* 5. PHOTO-PAIR BLOCK */} {block.type === 'photo-pair' && (
{/* Photo 1 */}
Photo 1
{block.assetIds[0] ? ( ) : (
)}
{onSelectPhoto && ( )} handleUpdateBlock(idx, { ...block, assetIds: [e.target.value, block.assetIds[1]], }) } placeholder="UUID 1..." style={{ fontSize: '0.75rem' }} />
{/* Photo 2 */}
Photo 2
{block.assetIds[1] ? ( ) : (
)}
{onSelectPhoto && ( )} handleUpdateBlock(idx, { ...block, assetIds: [block.assetIds[0], e.target.value], }) } placeholder="UUID 2..." style={{ fontSize: '0.75rem' }} />
handleUpdateBlock(idx, { ...block, caption: caption || undefined }) } placeholder="Side-by-side caption (optional)" />
)} {/* 6. PHOTO-GRID BLOCK */} {block.type === 'photo-grid' && (
{block.assetIds.map((assetId, pIdx) => (
Photo {pIdx + 1} {block.assetIds.length > 3 && ( )}
{assetId ? ( ) : (
)}
{onSelectPhoto && ( )} { const ids = [...block.assetIds]; ids[pIdx] = e.target.value; handleUpdateBlock(idx, { ...block, assetIds: ids }); }} placeholder={`UUID ${pIdx + 1}...`} style={{ fontSize: '0.75rem' }} />
))}
handleUpdateBlock(idx, { ...block, caption: caption || undefined }) } placeholder="Grid caption (optional)" />
)} {/* 7. FACTS BLOCK */} {block.type === 'facts' && (
{block.items.map((item, fIdx) => (
handleUpdateBlock(idx, { ...block, items: block.items.map((it, i) => i === fIdx ? { ...it, label: e.target.value } : it, ), }) } /> handleUpdateBlock(idx, { ...block, items: block.items.map((it, i) => (i === fIdx ? { ...it, value } : it)), }) } />
))}
)} {/* 9. ALBUM BLOCK */} {block.type === 'album' && (
handleUpdateBlock(idx, { ...block, albumId: e.target.value })} />
handleUpdateBlock(idx, { ...block, count: e.target.value ? Number(e.target.value) : undefined, }) } /> handleUpdateBlock(idx, { ...block, skip: e.target.value ? Number(e.target.value) : undefined, }) } />
handleUpdateBlock(idx, { ...block, caption: caption || undefined }) } placeholder="Caption for the set (optional)" /> Expanded into photo blocks when the page renders — the album's order, a manual gallery order first. Leave count empty for the whole album.
)} {/* 8. MAP BLOCK */} {block.type === 'map' && (
handleUpdateBlock(idx, { ...block, caption: caption || undefined }) } placeholder="Caption, e.g. Busan → Seoul (optional)" /> {block.items.map((item, mIdx) => { const setItem = (next: MapItem) => handleUpdateBlock(idx, { ...block, items: block.items.map((it, i) => (i === mIdx ? next : it)), }); return (
{mIdx + 1} {item.kind === 'point' && ( <> setItem({ ...item, label: e.target.value || undefined }) } /> setItem({ ...item, lat: parseFloat(e.target.value) })} /> setItem({ ...item, lng: parseFloat(e.target.value) })} /> )} {item.kind === 'photo' && ( <> {onSelectPhoto && ( )} setItem({ kind: 'photo', assetId: e.target.value })} /> )} {item.kind === 'all-photos' && ( All geotagged photos of this page's albums, in order )}
); })} {block.items.some( (it) => it.kind === 'point' && !isValidCoordinate(it.lat, it.lng), ) && (

A point needs a latitude within ±90 and a longitude within ±180; points without valid coordinates are not saved.

)}
Typed points are published as typed. Photo pins are placed from the photo's GPS on the live page, under the album's location precision. Renders only while the map is enabled in Settings.
)}
))}
); }