1037 lines
39 KiB
TypeScript
1037 lines
39 KiB
TypeScript
'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';
|
|
|
|
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<ParsedEssay>(() =>
|
|
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 (
|
|
<div className="essay-block-editor">
|
|
{/* Sticky Dark Glass Toolbar */}
|
|
<div className="essay-toolbar-sticky">
|
|
<span className="essay-toolbar-label">Add Story Block:</span>
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-xs"
|
|
onClick={() => handleAddBlock('paragraph')}
|
|
>
|
|
<IconFileText size={13} /> + Paragraph
|
|
</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} /> + Pullquote
|
|
</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')}
|
|
>
|
|
<IconCamera 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>
|
|
|
|
{essay.blocks.length === 0 && (
|
|
<p className="empty-hint" style={{ textAlign: 'center', padding: '2rem' }}>
|
|
No story blocks yet. Use the buttons above to build your photo essay!
|
|
</p>
|
|
)}
|
|
|
|
{/* Render Blocks */}
|
|
<SortableBlockList count={essay.blocks.length} onReorder={handleReorderBlock}>
|
|
{essay.blocks.map((block, idx) => (
|
|
<SortableBlockCard
|
|
key={idx}
|
|
index={idx}
|
|
badge={<BlockBadge type={block.type} index={idx + 1} />}
|
|
actions={
|
|
<div className="essay-block-actions">
|
|
<button
|
|
type="button"
|
|
className="admin-btn-icon"
|
|
onClick={() => handleMoveBlock(idx, 'up')}
|
|
disabled={idx === 0}
|
|
title="Move Up"
|
|
>
|
|
<IconChevronUp size={15} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="admin-btn-icon"
|
|
onClick={() => handleMoveBlock(idx, 'down')}
|
|
disabled={idx === essay.blocks.length - 1}
|
|
title="Move Down"
|
|
>
|
|
<IconChevronDown size={15} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="admin-btn-icon"
|
|
onClick={() => handleDeleteBlock(idx)}
|
|
title="Delete Block"
|
|
style={{ color: 'var(--admin-error)' }}
|
|
>
|
|
<IconTrash size={15} />
|
|
</button>
|
|
</div>
|
|
}
|
|
>
|
|
{/* Block Form Fields */}
|
|
|
|
{/* 1. HEADING BLOCK */}
|
|
{block.type === 'heading' && (
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
|
<select
|
|
aria-label="Heading level"
|
|
value={block.level}
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, { ...block, level: Number(e.target.value) })
|
|
}
|
|
style={{ width: '70px' }}
|
|
>
|
|
<option value={1}>H1</option>
|
|
<option value={2}>H2</option>
|
|
<option value={3}>H3</option>
|
|
</select>
|
|
<input
|
|
type="text"
|
|
value={block.text}
|
|
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
|
placeholder="Section heading..."
|
|
style={{
|
|
flex: 1,
|
|
fontWeight: block.level === 1 ? 700 : block.level === 2 ? 600 : 500,
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 2. PARAGRAPH BLOCK
|
|
|
|
The textarea shows `html` raw, as the journal editor does. It used
|
|
to strip the tags for display — `<strong>` to `**`, everything else
|
|
to nothing — while `onChange` wrote the stripped text straight back
|
|
into `html`. Italics, links, anything the markdown had produced was
|
|
destroyed by the act of editing the paragraph, and the two editors
|
|
disagreed about the same file. serializeJournalMarkdown() turns the
|
|
HTML back into markdown on save and handles `<em>` too, so there
|
|
was nothing to convert here in the first place. */}
|
|
{block.type === 'paragraph' && (
|
|
<textarea
|
|
rows={3}
|
|
value={block.html}
|
|
onChange={(e) => handleUpdateBlock(idx, { ...block, html: e.target.value })}
|
|
placeholder="Write text paragraph... (**bold**, *italic* markdown supported)"
|
|
/>
|
|
)}
|
|
|
|
{/* 3. PULLQUOTE BLOCK */}
|
|
{block.type === 'quote' && (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '8px',
|
|
borderLeft: '3px solid var(--admin-accent)',
|
|
paddingLeft: '10px',
|
|
}}
|
|
>
|
|
<textarea
|
|
rows={2}
|
|
value={block.text}
|
|
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
|
placeholder="Quote text..."
|
|
style={{ fontStyle: 'italic', fontFamily: 'serif', fontSize: '1rem' }}
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={block.author || ''}
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, { ...block, author: e.target.value || undefined })
|
|
}
|
|
placeholder="— Author / Attribution (optional)"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 4. PHOTO BLOCK */}
|
|
{block.type === 'photo' && (
|
|
<div className="essay-photo-card">
|
|
<div className="essay-photo-preview-container">
|
|
{block.assetId ? (
|
|
<img
|
|
src={`/api/admin/thumbnail/${block.assetId}`}
|
|
alt=""
|
|
className="essay-photo-thumbnail"
|
|
onError={(e) => {
|
|
(e.target as HTMLElement).style.display = 'none';
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="essay-photo-empty">
|
|
<IconCamera size={20} />
|
|
<span>No Photo</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="essay-photo-meta">
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
|
{onSelectPhoto && (
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-sm admin-btn-primary"
|
|
onClick={() =>
|
|
onSelectPhoto((pickedId) =>
|
|
handleUpdateBlock(idx, { ...block, assetId: pickedId }),
|
|
)
|
|
}
|
|
>
|
|
<IconCamera size={14} />
|
|
{block.assetId ? 'Change Photo' : 'Select Photo'}
|
|
</button>
|
|
)}
|
|
|
|
<div className="essay-pill-selector">
|
|
<button
|
|
type="button"
|
|
className={`essay-pill-btn ${block.layout === 'contained' ? 'active' : ''}`}
|
|
onClick={() => handleUpdateBlock(idx, { ...block, layout: 'contained' })}
|
|
>
|
|
Contained (68ch)
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`essay-pill-btn ${block.layout === 'wide' ? 'active' : ''}`}
|
|
onClick={() => handleUpdateBlock(idx, { ...block, layout: 'wide' })}
|
|
>
|
|
Wide (1100px)
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`essay-pill-btn ${block.layout === 'fullbleed' ? 'active' : ''}`}
|
|
onClick={() => handleUpdateBlock(idx, { ...block, layout: 'fullbleed' })}
|
|
>
|
|
Fullbleed (100vw)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<input
|
|
type="text"
|
|
value={block.assetId}
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, { ...block, assetId: e.target.value })
|
|
}
|
|
placeholder="Immich Asset UUID..."
|
|
style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<input
|
|
type="text"
|
|
value={block.caption || ''}
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
|
}
|
|
placeholder="Photo caption (optional)"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 5. PHOTO-PAIR BLOCK */}
|
|
{block.type === 'photo-pair' && (
|
|
<div className="essay-photo-card">
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
|
{/* Photo 1 */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
<span
|
|
style={{
|
|
fontSize: '0.75rem',
|
|
fontWeight: 600,
|
|
color: 'var(--admin-text-secondary)',
|
|
}}
|
|
>
|
|
Photo 1
|
|
</span>
|
|
<div className="essay-photo-preview-container">
|
|
{block.assetIds[0] ? (
|
|
<img
|
|
src={`/api/admin/thumbnail/${block.assetIds[0]}`}
|
|
alt=""
|
|
className="essay-photo-thumbnail"
|
|
style={{ width: '80px', height: '60px' }}
|
|
/>
|
|
) : (
|
|
<div
|
|
className="essay-photo-empty"
|
|
style={{ width: '80px', height: '60px' }}
|
|
>
|
|
<IconCamera size={16} />
|
|
</div>
|
|
)}
|
|
<div
|
|
style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}
|
|
>
|
|
{onSelectPhoto && (
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-xs"
|
|
onClick={() =>
|
|
onSelectPhoto((pickedId) =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
assetIds: [pickedId, block.assetIds[1]],
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
<IconCamera size={12} /> Select Photo 1
|
|
</button>
|
|
)}
|
|
<input
|
|
type="text"
|
|
value={block.assetIds[0]}
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
assetIds: [e.target.value, block.assetIds[1]],
|
|
})
|
|
}
|
|
placeholder="UUID 1..."
|
|
style={{ fontSize: '0.75rem' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Photo 2 */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: '0.75rem',
|
|
fontWeight: 600,
|
|
color: 'var(--admin-text-secondary)',
|
|
}}
|
|
>
|
|
Photo 2
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="admin-btn-icon"
|
|
title="Swap Photo 1 & Photo 2"
|
|
onClick={() =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
assetIds: [block.assetIds[1], block.assetIds[0]],
|
|
})
|
|
}
|
|
>
|
|
<IconArrowLeftRight size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="essay-photo-preview-container">
|
|
{block.assetIds[1] ? (
|
|
<img
|
|
src={`/api/admin/thumbnail/${block.assetIds[1]}`}
|
|
alt=""
|
|
className="essay-photo-thumbnail"
|
|
style={{ width: '80px', height: '60px' }}
|
|
/>
|
|
) : (
|
|
<div
|
|
className="essay-photo-empty"
|
|
style={{ width: '80px', height: '60px' }}
|
|
>
|
|
<IconCamera size={16} />
|
|
</div>
|
|
)}
|
|
<div
|
|
style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}
|
|
>
|
|
{onSelectPhoto && (
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-xs"
|
|
onClick={() =>
|
|
onSelectPhoto((pickedId) =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
assetIds: [block.assetIds[0], pickedId],
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
<IconCamera size={12} /> Select Photo 2
|
|
</button>
|
|
)}
|
|
<input
|
|
type="text"
|
|
value={block.assetIds[1]}
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
assetIds: [block.assetIds[0], e.target.value],
|
|
})
|
|
}
|
|
placeholder="UUID 2..."
|
|
style={{ fontSize: '0.75rem' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<input
|
|
type="text"
|
|
value={block.caption || ''}
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
|
}
|
|
placeholder="Side-by-side caption (optional)"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 6. PHOTO-GRID BLOCK */}
|
|
{block.type === 'photo-grid' && (
|
|
<div className="essay-photo-card">
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(170px, 1fr))',
|
|
gap: '1rem',
|
|
}}
|
|
>
|
|
{block.assetIds.map((assetId, pIdx) => (
|
|
<div
|
|
key={pIdx}
|
|
style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: '0.75rem',
|
|
fontWeight: 600,
|
|
color: 'var(--admin-text-secondary)',
|
|
}}
|
|
>
|
|
Photo {pIdx + 1}
|
|
</span>
|
|
{block.assetIds.length > 3 && (
|
|
<button
|
|
type="button"
|
|
className="admin-btn-icon"
|
|
title="Remove photo"
|
|
onClick={() =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
assetIds: block.assetIds.filter((_, i) => i !== pIdx),
|
|
})
|
|
}
|
|
>
|
|
<IconX size={13} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="essay-photo-preview-container">
|
|
{assetId ? (
|
|
<img
|
|
src={`/api/admin/thumbnail/${assetId}`}
|
|
alt=""
|
|
className="essay-photo-thumbnail"
|
|
style={{ width: '80px', height: '60px' }}
|
|
/>
|
|
) : (
|
|
<div
|
|
className="essay-photo-empty"
|
|
style={{ width: '80px', height: '60px' }}
|
|
>
|
|
<IconCamera size={16} />
|
|
</div>
|
|
)}
|
|
<div
|
|
style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}
|
|
>
|
|
{onSelectPhoto && (
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-xs"
|
|
onClick={() =>
|
|
onSelectPhoto((pickedId) => {
|
|
const ids = [...block.assetIds];
|
|
ids[pIdx] = pickedId;
|
|
handleUpdateBlock(idx, { ...block, assetIds: ids });
|
|
})
|
|
}
|
|
>
|
|
<IconCamera size={12} /> Select
|
|
</button>
|
|
)}
|
|
<input
|
|
type="text"
|
|
value={assetId}
|
|
onChange={(e) => {
|
|
const ids = [...block.assetIds];
|
|
ids[pIdx] = e.target.value;
|
|
handleUpdateBlock(idx, { ...block, assetIds: ids });
|
|
}}
|
|
placeholder={`UUID ${pIdx + 1}...`}
|
|
style={{ fontSize: '0.75rem' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-xs"
|
|
style={{ alignSelf: 'flex-start' }}
|
|
onClick={() =>
|
|
handleUpdateBlock(idx, { ...block, assetIds: [...block.assetIds, ''] })
|
|
}
|
|
>
|
|
<IconPlus size={12} /> Add photo
|
|
</button>
|
|
|
|
<input
|
|
type="text"
|
|
value={block.caption || ''}
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
|
}
|
|
placeholder="Grid caption (optional)"
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* 7. FACTS BLOCK */}
|
|
{block.type === 'facts' && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
{block.items.map((item, fIdx) => (
|
|
<div
|
|
key={fIdx}
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: '1fr 2fr auto',
|
|
gap: '8px',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<input
|
|
type="text"
|
|
value={item.label}
|
|
placeholder="Label, e.g. Distance"
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
items: block.items.map((it, i) =>
|
|
i === fIdx ? { ...it, label: e.target.value } : it,
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={item.value}
|
|
placeholder="Value, e.g. 21 km"
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
items: block.items.map((it, i) =>
|
|
i === fIdx ? { ...it, value: e.target.value } : it,
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="admin-btn-icon"
|
|
title="Remove fact"
|
|
disabled={block.items.length <= 1}
|
|
onClick={() =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
items: block.items.filter((_, i) => i !== fIdx),
|
|
})
|
|
}
|
|
>
|
|
<IconX size={13} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-xs"
|
|
style={{ alignSelf: 'flex-start' }}
|
|
onClick={() =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
items: [...block.items, { label: '', value: '' }],
|
|
})
|
|
}
|
|
>
|
|
<IconPlus size={12} /> Add fact
|
|
</button>
|
|
</div>
|
|
)}
|
|
{/* 9. ALBUM BLOCK */}
|
|
{block.type === 'album' && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
<input
|
|
type="text"
|
|
value={block.albumId}
|
|
placeholder="Immich album UUID (the album picker in Pages shows ids)"
|
|
style={{ fontSize: '0.75rem' }}
|
|
onChange={(e) => handleUpdateBlock(idx, { ...block, albumId: e.target.value })}
|
|
/>
|
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
placeholder="Count (all)"
|
|
aria-label="Count"
|
|
style={{ flex: '1 1 100px' }}
|
|
value={block.count ?? ''}
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
count: e.target.value ? Number(e.target.value) : undefined,
|
|
})
|
|
}
|
|
/>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
placeholder="Skip (0)"
|
|
aria-label="Skip"
|
|
style={{ flex: '1 1 100px' }}
|
|
value={block.skip ?? ''}
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
skip: e.target.value ? Number(e.target.value) : undefined,
|
|
})
|
|
}
|
|
/>
|
|
<select
|
|
aria-label="Layout"
|
|
style={{ flex: '1 1 140px' }}
|
|
value={block.layout}
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, {
|
|
...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>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={block.caption || ''}
|
|
placeholder="Caption for the set (optional)"
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
|
}
|
|
/>
|
|
<span style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }}>
|
|
Expanded into photo blocks when the page renders — the album's order, a
|
|
manual gallery order first. Leave count empty for the whole album.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 8. MAP BLOCK */}
|
|
{block.type === 'map' && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
<input
|
|
type="text"
|
|
value={block.caption || ''}
|
|
onChange={(e) =>
|
|
handleUpdateBlock(idx, { ...block, caption: e.target.value || 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 (
|
|
<div
|
|
key={mIdx}
|
|
style={{
|
|
display: 'flex',
|
|
gap: '6px',
|
|
alignItems: 'center',
|
|
flexWrap: 'wrap',
|
|
}}
|
|
>
|
|
<span style={{ fontSize: '0.7rem', color: 'var(--admin-text-secondary)' }}>
|
|
{mIdx + 1}
|
|
</span>
|
|
{item.kind === 'point' && (
|
|
<>
|
|
<input
|
|
type="text"
|
|
value={item.label ?? ''}
|
|
placeholder="Label (optional)"
|
|
style={{ flex: '1 1 140px' }}
|
|
onChange={(e) =>
|
|
setItem({ ...item, label: e.target.value || undefined })
|
|
}
|
|
/>
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
aria-label="Latitude"
|
|
placeholder="Lat"
|
|
style={{ flex: '0 1 110px' }}
|
|
value={Number.isFinite(item.lat) ? item.lat : ''}
|
|
onChange={(e) => setItem({ ...item, lat: parseFloat(e.target.value) })}
|
|
/>
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
aria-label="Longitude"
|
|
placeholder="Lng"
|
|
style={{ flex: '0 1 110px' }}
|
|
value={Number.isFinite(item.lng) ? item.lng : ''}
|
|
onChange={(e) => setItem({ ...item, lng: parseFloat(e.target.value) })}
|
|
/>
|
|
</>
|
|
)}
|
|
{item.kind === 'photo' && (
|
|
<>
|
|
{onSelectPhoto && (
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-xs"
|
|
onClick={() =>
|
|
onSelectPhoto((pickedId) =>
|
|
setItem({ kind: 'photo', assetId: pickedId }),
|
|
)
|
|
}
|
|
>
|
|
<IconCamera size={12} /> {item.assetId ? 'Change' : 'Select'}
|
|
</button>
|
|
)}
|
|
<input
|
|
type="text"
|
|
value={item.assetId}
|
|
placeholder="Photo UUID..."
|
|
style={{ flex: '1 1 200px', fontSize: '0.75rem' }}
|
|
onChange={(e) => setItem({ kind: 'photo', assetId: e.target.value })}
|
|
/>
|
|
</>
|
|
)}
|
|
{item.kind === 'all-photos' && (
|
|
<span style={{ flex: 1, fontSize: '0.8rem' }}>
|
|
All geotagged photos of this page's albums, in order
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="admin-btn-icon"
|
|
title="Remove pin"
|
|
onClick={() =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
items: block.items.filter((_, i) => i !== mIdx),
|
|
})
|
|
}
|
|
>
|
|
<IconX size={13} />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{block.items.some(
|
|
(it) => it.kind === 'point' && !isValidCoordinate(it.lat, it.lng),
|
|
) && (
|
|
<p className="empty-hint" style={{ color: 'var(--admin-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={() =>
|
|
handleUpdateBlock(idx, {
|
|
...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={() =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
items: [...block.items, { kind: 'photo', assetId: '' }],
|
|
})
|
|
}
|
|
>
|
|
<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={() =>
|
|
handleUpdateBlock(idx, {
|
|
...block,
|
|
items: [...block.items, { kind: 'all-photos' }],
|
|
})
|
|
}
|
|
>
|
|
<IconMap size={12} /> All geotagged photos
|
|
</button>
|
|
<label
|
|
style={{
|
|
marginLeft: 'auto',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
fontSize: '0.8rem',
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={block.line}
|
|
onChange={(e) => handleUpdateBlock(idx, { ...block, line: e.target.checked })}
|
|
/>
|
|
Connect pins with a line
|
|
</label>
|
|
</div>
|
|
|
|
<span style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }}>
|
|
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.
|
|
</span>
|
|
</div>
|
|
)}
|
|
</SortableBlockCard>
|
|
))}
|
|
</SortableBlockList>
|
|
</div>
|
|
);
|
|
}
|