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>
714 lines
25 KiB
TypeScript
714 lines
25 KiB
TypeScript
'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 { InlineMarkdownField } from '../InlineMarkdownField';
|
|
import { fillAssetSlots, 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;
|
|
/** Multi-select: several picks confirmed at once (#602). */
|
|
onSelectMany?: (assetIds: string[]) => void;
|
|
/** Most assets `onSelectMany` takes. */
|
|
max?: number;
|
|
}
|
|
|
|
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' && (
|
|
<InlineMarkdownField
|
|
multiline
|
|
aria-label="Text"
|
|
className="admin-input"
|
|
rows={3}
|
|
placeholder="Text (**bold**, *italic*, [link](https://…))"
|
|
html={block.html}
|
|
onChange={(html) => onChange({ ...block, html })}
|
|
/>
|
|
)}
|
|
|
|
{block.type === 'quote' && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
<InlineMarkdownField
|
|
multiline
|
|
className="admin-input"
|
|
rows={2}
|
|
html={block.text}
|
|
placeholder="Quote text..."
|
|
onChange={(text) => onChange({ ...block, text })}
|
|
/>
|
|
<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 "{block.assetId}" 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>
|
|
<InlineMarkdownField
|
|
className="admin-input"
|
|
placeholder="Caption (optional)"
|
|
html={block.caption}
|
|
onChange={(caption) => onChange({ ...block, caption })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{block.type === 'photo-pair' && block.assetIds.some(isLegacyAssetRef) && (
|
|
<p className="journal-block-warning">
|
|
References "{block.assetIds.filter(isLegacyAssetRef).join('", "')}
|
|
" 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>
|
|
<div>
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-xs"
|
|
onClick={() =>
|
|
onPickAsset({
|
|
title: 'Select Photos for Pair',
|
|
max: 2,
|
|
onSelectMany: (ids) => {
|
|
// Two picks replace the pair; a single pick fills a gap.
|
|
const base = ids.length >= 2 ? ['', ''] : [...block.assetIds];
|
|
const [a, b] = fillAssetSlots(base, ids, 2);
|
|
onChange({ ...block, assetIds: [a ?? '', b ?? ''] });
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<IconCamera size={12} /> Pick both photos
|
|
</button>
|
|
</div>
|
|
<InlineMarkdownField
|
|
className="admin-input"
|
|
placeholder="Shared caption for pair (optional)"
|
|
html={block.caption}
|
|
onChange={(caption) => onChange({ ...block, caption })}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{block.type === 'photo-grid' && block.assetIds.some(isLegacyAssetRef) && (
|
|
<p className="journal-block-warning">
|
|
References "{block.assetIds.filter(isLegacyAssetRef).join('", "')}
|
|
" 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>
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-xs"
|
|
onClick={() =>
|
|
onPickAsset({
|
|
title: 'Select Photos for Grid',
|
|
onSelectMany: (ids) =>
|
|
onChange({ ...block, assetIds: fillAssetSlots(block.assetIds, ids) }),
|
|
})
|
|
}
|
|
>
|
|
<IconCamera size={12} /> Pick several
|
|
</button>
|
|
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
|
Three or more photos, laid out in rows of three.
|
|
</span>
|
|
</div>
|
|
<InlineMarkdownField
|
|
className="admin-input"
|
|
placeholder="Shared caption for grid (optional)"
|
|
html={block.caption}
|
|
onChange={(caption) => onChange({ ...block, caption })}
|
|
/>
|
|
</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,
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
<InlineMarkdownField
|
|
className="admin-input"
|
|
placeholder="Value, e.g. 21 km"
|
|
html={item.value}
|
|
onChange={(value) =>
|
|
onChange({
|
|
...block,
|
|
items: block.items.map((it, i) => (i === fIdx ? { ...it, 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>
|
|
<InlineMarkdownField
|
|
className="admin-input"
|
|
placeholder="Caption for the set (optional)"
|
|
html={block.caption}
|
|
onChange={(caption) => onChange({ ...block, caption: caption || undefined })}
|
|
/>
|
|
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
|
Photos follow the album'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>
|
|
)}
|
|
<InlineMarkdownField
|
|
className="admin-input"
|
|
placeholder="Caption, e.g. Busan → Seoul (optional)"
|
|
html={block.caption}
|
|
onChange={(caption) => onChange({ ...block, caption })}
|
|
/>
|
|
|
|
{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's GPS, under the album's location precision.
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|