release: v0.17.0 (#677)
See CHANGELOG.md, section [0.17.0] — 2026-09-23.
This commit is contained in:
684
app/admin/components/journal/BlockFields.tsx
Normal file
684
app/admin/components/journal/BlockFields.tsx
Normal file
@@ -0,0 +1,684 @@
|
||||
'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 { 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;
|
||||
}
|
||||
|
||||
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' && (
|
||||
<textarea
|
||||
className="admin-input"
|
||||
rows={3}
|
||||
value={block.html}
|
||||
onChange={(e) => onChange({ ...block, html: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{block.type === 'quote' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<textarea
|
||||
className="admin-input"
|
||||
rows={2}
|
||||
value={block.text}
|
||||
placeholder="Quote text..."
|
||||
onChange={(e) => onChange({ ...block, text: e.target.value })}
|
||||
/>
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
placeholder="Caption (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
||||
/>
|
||||
</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>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
placeholder="Shared caption for pair (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
||||
/>
|
||||
</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>
|
||||
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
||||
Three or more photos, laid out in rows of three.
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
placeholder="Shared caption for grid (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
||||
/>
|
||||
</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,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
placeholder="Value, e.g. 21 km"
|
||||
value={item.value}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...block,
|
||||
items: block.items.map((it, i) =>
|
||||
i === fIdx ? { ...it, value: e.target.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>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
placeholder="Caption for the set (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...block,
|
||||
caption: e.target.value || 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>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
placeholder="Caption, e.g. Busan → Seoul (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
||||
/>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
607
app/admin/components/journal/JournalEditor.tsx
Normal file
607
app/admin/components/journal/JournalEditor.tsx
Normal file
@@ -0,0 +1,607 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { ParsedJournal, JournalBlock } from '@/lib/journal';
|
||||
import { parseJournalMarkdown, serializeJournalMarkdown, collectAssetIds } from '@/lib/journal';
|
||||
import {
|
||||
IconFileText,
|
||||
IconSparkles,
|
||||
IconQuote,
|
||||
IconCamera,
|
||||
IconArrowLeftRight,
|
||||
IconTrash,
|
||||
IconChevronUp,
|
||||
IconChevronDown,
|
||||
IconGear,
|
||||
IconLink,
|
||||
IconCheck,
|
||||
IconGrid,
|
||||
IconColumns,
|
||||
IconMap,
|
||||
IconFolder,
|
||||
} from '../Icons';
|
||||
import AssetPicker from '../AssetPicker';
|
||||
import AlbumPicker from '../AlbumPicker';
|
||||
import { SortableBlockList, SortableBlockCard } from '../SortableBlocks';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
import type { AlbumAssetRef } from '@/lib/journalAlbum';
|
||||
import { BlockBadge } from '../BlockBadge';
|
||||
import { useUnsavedGuard } from '../useUnsavedGuard';
|
||||
import { useDraft } from '../useDraft';
|
||||
import DraftNotice from '../DraftNotice';
|
||||
import { reportIfSessionExpired } from '../sessionExpiry';
|
||||
import './journal-studio.css';
|
||||
import { BlockFields, type AssetPickTarget } from './BlockFields';
|
||||
import { StorySettingsModal } from './StorySettingsModal';
|
||||
import { JournalPreview } from './JournalPreview';
|
||||
import { createBlock, moveBlock } from './blockOps';
|
||||
import { useSplitPane, SPLIT_MIN, SPLIT_MAX } from './splitPane';
|
||||
|
||||
interface JournalEditorProps {
|
||||
slug: string;
|
||||
mapEnabled?: boolean;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<'blocks' | 'markdown'>('blocks');
|
||||
|
||||
// Draggable divider between authoring pane and preview.
|
||||
const {
|
||||
splitRef,
|
||||
splitPct,
|
||||
dragging,
|
||||
onResizerMouseDown,
|
||||
onResizerDoubleClick,
|
||||
onResizerKeyDown,
|
||||
} = useSplitPane();
|
||||
|
||||
const [rawMarkdown, setRawMarkdown] = useState('');
|
||||
const [parsed, setParsed] = useState<ParsedJournal>(() => ({
|
||||
frontmatter: {},
|
||||
blocks: [],
|
||||
referencedAssetIds: [],
|
||||
}));
|
||||
|
||||
// Settings / Metadata Modal
|
||||
const [showMetaModal, setShowMetaModal] = useState(false);
|
||||
|
||||
// Asset Picker State
|
||||
const [assetPickerTarget, setAssetPickerTarget] = useState<AssetPickTarget | null>(null);
|
||||
|
||||
/** Set when the entry could not be fetched; blocks saving over it. */
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// Unsaved edits survive leaving the editor — the entry list, a tab link,
|
||||
// back, Reload (#592). The markdown is the whole entry; blocks and
|
||||
// frontmatter are parsed from it. `serverMarkdown` is what a discard restores.
|
||||
const draft = useDraft<string>(`journal-${slug}`, rawMarkdown, dirty);
|
||||
const loadDraft = draft.load;
|
||||
const serverMarkdown = useRef('');
|
||||
|
||||
// Load entry
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/journal/${slug}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
res.status === 401
|
||||
? 'Your session has expired. Sign in again to continue.'
|
||||
: `The server answered ${res.status}.`,
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
const md: string = data.entry.rawMarkdown;
|
||||
serverMarkdown.current = md;
|
||||
const restored = loadDraft(md);
|
||||
setRawMarkdown(restored ?? md);
|
||||
setParsed(parseJournalMarkdown(restored ?? md));
|
||||
setDirty(restored !== null);
|
||||
} catch (err) {
|
||||
console.error('Failed to load journal entry:', err);
|
||||
// An empty editor saved over the entry replaces it with nothing, so
|
||||
// this blocks the save rather than only reporting it.
|
||||
setLoadError(err instanceof Error ? err.message : 'The entry could not be loaded.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [slug, loadDraft]);
|
||||
|
||||
// Update markdown and sync blocks
|
||||
const handleMarkdownChange = (newMd: string) => {
|
||||
setRawMarkdown(newMd);
|
||||
setParsed(parseJournalMarkdown(newMd));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
// Update structured blocks and sync markdown
|
||||
const handleBlocksChange = (newBlocks: JournalBlock[]) => {
|
||||
const updated: ParsedJournal = {
|
||||
...parsed,
|
||||
blocks: newBlocks,
|
||||
referencedAssetIds: collectAssetIds(newBlocks),
|
||||
};
|
||||
const serialized = serializeJournalMarkdown(updated);
|
||||
setParsed(updated);
|
||||
setRawMarkdown(serialized);
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
// Update frontmatter
|
||||
const handleFrontmatterChange = (updates: Partial<ParsedJournal['frontmatter']>) => {
|
||||
const updated: ParsedJournal = {
|
||||
...parsed,
|
||||
frontmatter: {
|
||||
...parsed.frontmatter,
|
||||
...updates,
|
||||
},
|
||||
};
|
||||
const serialized = serializeJournalMarkdown(updated);
|
||||
setParsed(updated);
|
||||
setRawMarkdown(serialized);
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
// Save
|
||||
const handleSave = async () => {
|
||||
// The editor is not rendered in this state, but Cmd+S still reaches here.
|
||||
if (loadError) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/journal/${slug}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rawMarkdown,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
serverMarkdown.current = rawMarkdown;
|
||||
draft.saved(rawMarkdown);
|
||||
setDirty(false);
|
||||
} else if (!reportIfSessionExpired(res)) {
|
||||
const data = await res.json();
|
||||
alert(data.error || 'Failed to save');
|
||||
}
|
||||
} catch {
|
||||
alert('Error saving entry');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useUnsavedGuard(dirty);
|
||||
|
||||
const discardDraft = () => {
|
||||
draft.discard();
|
||||
setRawMarkdown(serverMarkdown.current);
|
||||
setParsed(parseJournalMarkdown(serverMarkdown.current));
|
||||
setDirty(false);
|
||||
};
|
||||
|
||||
const restoreConflictingDraft = () => {
|
||||
const value = draft.takeConflicting();
|
||||
if (value === null) return;
|
||||
setRawMarkdown(value);
|
||||
setParsed(parseJournalMarkdown(value));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
// Keyboard shortcut: Cmd+S / Ctrl+S
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [handleSave]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/*
|
||||
* Album blocks are expanded for the preview the way the page expands them,
|
||||
* from the admin assets route; until an album has loaded its block simply
|
||||
* does not render. The picker's album list is fetched on first use.
|
||||
*/
|
||||
const [albumAssets, setAlbumAssets] = useState<Record<string, AlbumAssetRef[]>>({});
|
||||
const albumRequested = useRef(new Set<string>());
|
||||
const [albumList, setAlbumList] = useState<Parameters<typeof AlbumPicker>[0]['albums'] | null>(
|
||||
null,
|
||||
);
|
||||
const [albumPickerTarget, setAlbumPickerTarget] = useState<((albumId: string) => void) | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
for (const block of parsed.blocks) {
|
||||
if (block.type !== 'album' || !block.albumId || albumRequested.current.has(block.albumId))
|
||||
continue;
|
||||
const albumId = block.albumId;
|
||||
albumRequested.current.add(albumId);
|
||||
fetch(`/api/admin/albums/${albumId}/assets?types=all`)
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject(new Error(String(res.status)))))
|
||||
.then((data: { assets: AlbumAssetRef[] }) => {
|
||||
if (!cancelled) setAlbumAssets((prev) => ({ ...prev, [albumId]: data.assets }));
|
||||
})
|
||||
.catch(() => {
|
||||
// Let a retry happen after a save or a re-pick.
|
||||
albumRequested.current.delete(albumId);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [parsed.blocks]);
|
||||
|
||||
const openAlbumPicker = (onSelect: (albumId: string) => void) => {
|
||||
setAlbumPickerTarget(() => onSelect);
|
||||
if (albumList === null) {
|
||||
fetch('/api/admin/albums')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setAlbumList(data.albums ?? []))
|
||||
.catch(() => setAlbumList([]));
|
||||
}
|
||||
};
|
||||
|
||||
// Block manipulation
|
||||
const handleAddBlock = (type: JournalBlock['type']) => {
|
||||
handleBlocksChange([...parsed.blocks, createBlock(type)]);
|
||||
};
|
||||
|
||||
const handleMoveBlock = (index: number, direction: 'up' | 'down') => {
|
||||
const blocks = moveBlock(parsed.blocks, index, direction);
|
||||
if (blocks) handleBlocksChange(blocks);
|
||||
};
|
||||
|
||||
const handleReorderBlock = (from: number, to: number) => {
|
||||
handleBlocksChange(arrayMove(parsed.blocks, from, to));
|
||||
};
|
||||
|
||||
const handleDeleteBlock = (index: number) => {
|
||||
handleBlocksChange(parsed.blocks.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpdateBlock = (index: number, updated: JournalBlock) => {
|
||||
const blocks = [...parsed.blocks];
|
||||
blocks[index] = updated;
|
||||
handleBlocksChange(blocks);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '4rem', textAlign: 'center', opacity: 0.6 }}>
|
||||
Opening Journal Studio...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the editor rather than sitting above it: an empty editor saved
|
||||
* over the entry would replace the text with nothing.
|
||||
*/
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="admin-error" role="alert">
|
||||
<strong>This entry could not be loaded.</strong> {loadError}
|
||||
<p>
|
||||
Nothing has been changed. Saving stays disabled until it loads, so an empty editor cannot
|
||||
overwrite the entry.
|
||||
</p>
|
||||
<button className="admin-btn admin-btn-secondary" onClick={onBack}>
|
||||
Back to entries
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="journal-editor-container">
|
||||
{/* Top Bar */}
|
||||
<div className="journal-editor-topbar">
|
||||
<div className="journal-editor-topbar-left">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-sm admin-btn-secondary"
|
||||
onClick={onBack}
|
||||
>
|
||||
← All Entries
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="journal-editor-title-input"
|
||||
value={parsed.frontmatter.title || ''}
|
||||
placeholder="Story Title..."
|
||||
onChange={(e) => handleFrontmatterChange({ title: e.target.value })}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: '6px',
|
||||
padding: '2px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-btn admin-btn-xs ${editorMode === 'blocks' ? 'admin-btn-primary' : ''}`}
|
||||
onClick={() => setEditorMode('blocks')}
|
||||
>
|
||||
Visual Blocks
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-btn admin-btn-xs ${editorMode === 'markdown' ? 'admin-btn-primary' : ''}`}
|
||||
onClick={() => setEditorMode('markdown')}
|
||||
>
|
||||
Raw Markdown
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="journal-editor-topbar-right">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-sm admin-btn-secondary"
|
||||
onClick={() => setShowMetaModal(true)}
|
||||
>
|
||||
<IconGear size={14} /> Story Settings
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={`/journal/${slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-sm admin-btn-secondary"
|
||||
>
|
||||
<IconLink size={14} /> Live
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-sm admin-btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
'Saving...'
|
||||
) : dirty ? (
|
||||
'Save Changes'
|
||||
) : (
|
||||
<>
|
||||
<IconCheck size={14} /> Saved
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DraftNotice
|
||||
status={draft.status}
|
||||
subject="entry"
|
||||
onDiscard={discardDraft}
|
||||
onRestore={restoreConflictingDraft}
|
||||
onDismiss={draft.dismiss}
|
||||
/>
|
||||
|
||||
{/* Split Screen */}
|
||||
<div
|
||||
className={`journal-editor-split${dragging ? ' is-dragging' : ''}`}
|
||||
ref={splitRef}
|
||||
style={{ ['--journal-split-left' as string]: `${splitPct}%` }}
|
||||
>
|
||||
{/* Left: Authoring Pane */}
|
||||
<div className="journal-editor-pane-left">
|
||||
{editorMode === 'markdown' ? (
|
||||
<textarea
|
||||
className="journal-raw-markdown-editor"
|
||||
value={rawMarkdown}
|
||||
onChange={(e) => handleMarkdownChange(e.target.value)}
|
||||
placeholder="Write Markdown here..."
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{/* Add Block Toolbar */}
|
||||
<div className="essay-toolbar-sticky">
|
||||
<span className="essay-toolbar-label">Add Block:</span>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('paragraph')}
|
||||
>
|
||||
<IconFileText size={13} /> + Text
|
||||
</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} /> + Quote
|
||||
</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')}
|
||||
>
|
||||
<IconArrowLeftRight 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>
|
||||
|
||||
{/* Blocks List */}
|
||||
<SortableBlockList count={parsed.blocks.length} onReorder={handleReorderBlock}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
{parsed.blocks.map((block, idx) => (
|
||||
<SortableBlockCard
|
||||
key={idx}
|
||||
index={idx}
|
||||
badge={<BlockBadge type={block.type} />}
|
||||
actions={
|
||||
<div className="essay-block-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
disabled={idx === 0}
|
||||
aria-label="Move block up"
|
||||
onClick={() => handleMoveBlock(idx, 'up')}
|
||||
>
|
||||
<IconChevronUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
disabled={idx === parsed.blocks.length - 1}
|
||||
aria-label="Move block down"
|
||||
onClick={() => handleMoveBlock(idx, 'down')}
|
||||
>
|
||||
<IconChevronDown size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs admin-btn-danger"
|
||||
aria-label="Delete block"
|
||||
onClick={() => handleDeleteBlock(idx)}
|
||||
>
|
||||
<IconTrash size={12} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BlockFields
|
||||
block={block}
|
||||
onChange={(updated) => handleUpdateBlock(idx, updated)}
|
||||
onPickAsset={setAssetPickerTarget}
|
||||
onPickAlbum={openAlbumPicker}
|
||||
albumAssets={albumAssets}
|
||||
albumList={albumList}
|
||||
mapEnabled={mapEnabled}
|
||||
/>
|
||||
</SortableBlockCard>
|
||||
))}
|
||||
</div>
|
||||
</SortableBlockList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Draggable divider */}
|
||||
<div
|
||||
className="journal-editor-resizer"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize editor and preview"
|
||||
aria-valuenow={Math.round(splitPct)}
|
||||
aria-valuemin={SPLIT_MIN}
|
||||
aria-valuemax={SPLIT_MAX}
|
||||
tabIndex={0}
|
||||
onMouseDown={onResizerMouseDown}
|
||||
onDoubleClick={onResizerDoubleClick}
|
||||
onKeyDown={onResizerKeyDown}
|
||||
>
|
||||
<span className="journal-editor-resizer-grip" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
{/* Right: Live Preview Pane */}
|
||||
<JournalPreview parsed={parsed} albumAssets={albumAssets} />
|
||||
</div>
|
||||
|
||||
{/* Metadata Modal */}
|
||||
{showMetaModal && (
|
||||
<StorySettingsModal
|
||||
frontmatter={parsed.frontmatter}
|
||||
onChange={handleFrontmatterChange}
|
||||
onPickAsset={setAssetPickerTarget}
|
||||
onClose={() => setShowMetaModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Album Picker Modal */}
|
||||
{albumPickerTarget && albumList !== null && (
|
||||
<AlbumPicker
|
||||
albums={albumList}
|
||||
usedAlbumIds={new Set()}
|
||||
onSelect={(albumId) => {
|
||||
albumPickerTarget(albumId);
|
||||
setAlbumPickerTarget(null);
|
||||
}}
|
||||
onClose={() => setAlbumPickerTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Asset Picker Modal */}
|
||||
{assetPickerTarget && (
|
||||
<AssetPicker
|
||||
title={assetPickerTarget.title}
|
||||
onSelect={(id) => {
|
||||
assetPickerTarget.onSelect(id);
|
||||
setAssetPickerTarget(null);
|
||||
}}
|
||||
onClose={() => setAssetPickerTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
app/admin/components/journal/JournalPreview.tsx
Normal file
123
app/admin/components/journal/JournalPreview.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ParsedJournal } from '@/lib/journal';
|
||||
import { collectAssetIds } from '@/lib/journal';
|
||||
import { expandAlbumBlocks, type AlbumAssetRef } from '@/lib/journalAlbum';
|
||||
import { EssayView } from '@/app/[...path]/EssayView';
|
||||
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
|
||||
import { IconEye } from '../Icons';
|
||||
|
||||
interface JournalPreviewProps {
|
||||
parsed: ParsedJournal;
|
||||
/** Assets of the albums referenced by album blocks, as loaded so far. */
|
||||
albumAssets: Record<string, AlbumAssetRef[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's live preview: the entry rendered through the public
|
||||
* `EssayView`, with a desktop/mobile frame (#555). Album blocks are expanded
|
||||
* the way the page expands them, and photo proportions are measured from the
|
||||
* admin thumbnails.
|
||||
*/
|
||||
export function JournalPreview({ parsed, albumAssets }: JournalPreviewProps) {
|
||||
const [viewport, setViewport] = useState<'desktop' | 'mobile'>('desktop');
|
||||
|
||||
/** Real width/height ratios of the referenced photos, measured from thumbnails. */
|
||||
const [assetRatios, setAssetRatios] = useState<Record<string, number>>({});
|
||||
|
||||
const previewBlocks = useMemo(
|
||||
() => expandAlbumBlocks(parsed.blocks, (id) => albumAssets[id]).blocks,
|
||||
[parsed.blocks, albumAssets],
|
||||
);
|
||||
const previewIds = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set([
|
||||
...(parsed.frontmatter.coverAssetId ? [parsed.frontmatter.coverAssetId] : []),
|
||||
...collectAssetIds(previewBlocks),
|
||||
]),
|
||||
),
|
||||
[previewBlocks, parsed.frontmatter.coverAssetId],
|
||||
);
|
||||
|
||||
/*
|
||||
* The preview used to hard-code 3:2 for every photo, so the studio showed a
|
||||
* cropped, uniform grid while the published page laid the photos out by their
|
||||
* real proportions. There is no admin endpoint that reports asset dimensions,
|
||||
* so the thumbnails are measured once as they load.
|
||||
*/
|
||||
const measuredRef = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
for (const id of previewIds) {
|
||||
if (!id || measuredRef.current.has(id)) continue;
|
||||
measuredRef.current.add(id);
|
||||
|
||||
const probe = new window.Image();
|
||||
probe.onload = () => {
|
||||
if (cancelled || !probe.naturalHeight) return;
|
||||
setAssetRatios((prev) => ({
|
||||
...prev,
|
||||
[id]: probe.naturalWidth / probe.naturalHeight,
|
||||
}));
|
||||
};
|
||||
probe.onerror = () => {
|
||||
// Unresolvable reference — keep the fallback ratio, the block editor
|
||||
// already flags it.
|
||||
measuredRef.current.delete(id);
|
||||
};
|
||||
probe.src = `/api/admin/thumbnail/${id}`;
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [previewIds]);
|
||||
|
||||
// Mock PhotoItems for preview
|
||||
const previewAssets: PhotoItem[] = previewIds.map((id) => ({
|
||||
id,
|
||||
type: 'image',
|
||||
thumbUrl: `/api/admin/thumbnail/${id}`,
|
||||
previewUrl: `/api/admin/thumbnail/${id}`,
|
||||
exifUrl: `/api/exif/${id}`,
|
||||
// Falls back to 3:2 only until the real ratio has been measured.
|
||||
aspectRatio: assetRatios[id] ?? 1.5,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="journal-editor-pane-right">
|
||||
<div className="journal-preview-bar">
|
||||
<span className="journal-preview-bar-label">
|
||||
<IconEye size={13} /> Realtime Theme Preview
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-btn admin-btn-xs ${viewport === 'desktop' ? 'admin-btn-primary' : ''}`}
|
||||
onClick={() => setViewport('desktop')}
|
||||
>
|
||||
Desktop
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-btn admin-btn-xs ${viewport === 'mobile' ? 'admin-btn-primary' : ''}`}
|
||||
onClick={() => setViewport('mobile')}
|
||||
>
|
||||
Mobile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`journal-preview-frame ${viewport}`}>
|
||||
<EssayView
|
||||
essay={{ ...parsed, blocks: previewBlocks }}
|
||||
assets={previewAssets}
|
||||
title={parsed.frontmatter.title}
|
||||
subtitle={parsed.frontmatter.subtitle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
382
app/admin/components/journal/JournalStudio.tsx
Normal file
382
app/admin/components/journal/JournalStudio.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { JournalEntrySummary } from '@/lib/journal';
|
||||
import { serializeJournalMarkdown, sanitizeSlug } from '@/lib/journal';
|
||||
import { JOURNAL_TEMPLATES } from '@/lib/journalTemplates';
|
||||
import {
|
||||
IconTrash,
|
||||
IconLink,
|
||||
IconPlus,
|
||||
IconBook,
|
||||
IconClock,
|
||||
IconCalendar,
|
||||
IconLock,
|
||||
} from '../Icons';
|
||||
import './journal-studio.css';
|
||||
import { JournalEditor } from './JournalEditor';
|
||||
|
||||
interface JournalStudioProps {
|
||||
/** Entry to open, taken from the /admin/journal/[slug] route. */
|
||||
slug?: string;
|
||||
/** `config.map`, passed by the server route: a map block renders only when the site has a map. */
|
||||
mapEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioProps) {
|
||||
const router = useRouter();
|
||||
const [entries, setEntries] = useState<JournalEntrySummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// New Entry Modal
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newSlug, setNewSlug] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
/** null = start blank, matching the previous (only) behavior. */
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||
|
||||
const fetchEntries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/journal');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEntries(data.entries || []);
|
||||
} else {
|
||||
setError('Failed to load journal entries');
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to load journal entries');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries();
|
||||
}, [fetchEntries]);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newTitle.trim()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const slug = sanitizeSlug(newSlug || newTitle);
|
||||
const template = selectedTemplateId
|
||||
? JOURNAL_TEMPLATES.find((t) => t.id === selectedTemplateId)
|
||||
: undefined;
|
||||
const content = template
|
||||
? serializeJournalMarkdown({
|
||||
frontmatter: {
|
||||
title: newTitle.trim(),
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
draft: true,
|
||||
},
|
||||
blocks: template.blocks,
|
||||
referencedAssetIds: [],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const res = await fetch('/api/admin/journal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: newTitle.trim(),
|
||||
slug,
|
||||
...(content ? { content } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setShowCreateModal(false);
|
||||
setNewTitle('');
|
||||
setNewSlug('');
|
||||
setSelectedTemplateId(null);
|
||||
await fetchEntries();
|
||||
if (data.entry?.slug) {
|
||||
router.push(`/admin/journal/${data.entry.slug}`);
|
||||
}
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || 'Failed to create journal entry');
|
||||
}
|
||||
} catch {
|
||||
alert('Error creating journal entry');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (slug: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Delete "${slug}"?\n\nA copy is kept and can be restored from Backups on the dashboard.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/journal/${slug}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchEntries();
|
||||
if (activeSlug === slug) router.push('/admin/journal');
|
||||
} else {
|
||||
alert('Failed to delete journal entry');
|
||||
}
|
||||
} catch {
|
||||
alert('Error deleting journal entry');
|
||||
}
|
||||
};
|
||||
|
||||
if (activeSlug) {
|
||||
return (
|
||||
<JournalEditor
|
||||
slug={activeSlug}
|
||||
mapEnabled={mapEnabled}
|
||||
onBack={() => router.push('/admin/journal')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="journal-studio">
|
||||
<div className="journal-studio-header">
|
||||
<div>
|
||||
<h2>
|
||||
<IconBook size={20} /> Journal & Photo Essays
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0', opacity: 0.7, fontSize: '0.9rem' }}>
|
||||
Author visual stories, field notes, and longform photo essays with live preview.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-primary"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<IconPlus size={16} /> New Journal Entry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '3rem', textAlign: 'center', opacity: 0.6 }}>
|
||||
Loading journal entries...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: '3rem', textAlign: 'center', color: '#ef4444' }}>{error}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div style={{ padding: '5rem 2rem', textAlign: 'center', opacity: 0.6 }}>
|
||||
<h3>No journal entries yet</h3>
|
||||
<p style={{ margin: '8px 0 1.5rem', fontSize: '0.9rem' }}>
|
||||
Create your first story to share travel journals or wedding reportages.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-primary"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<IconPlus size={16} /> Create First Entry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="journal-list-grid">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.slug} className="journal-admin-card">
|
||||
<div className="journal-admin-card-cover">
|
||||
{entry.frontmatter.coverAssetId ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${entry.frontmatter.coverAssetId}`}
|
||||
alt={entry.frontmatter.title || entry.slug}
|
||||
/>
|
||||
) : (
|
||||
<IconBook size={36} className="svg-icon journal-card-cover-placeholder" />
|
||||
)}
|
||||
</div>
|
||||
<div className="journal-admin-card-body">
|
||||
<h3 className="journal-admin-card-title">
|
||||
{entry.frontmatter.title || entry.slug}
|
||||
</h3>
|
||||
<div className="journal-admin-card-slug">/journal/{entry.slug}</div>
|
||||
|
||||
<div className="journal-admin-card-meta">
|
||||
{entry.frontmatter.date && (
|
||||
<span>
|
||||
<IconCalendar size={12} /> {entry.frontmatter.date}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<IconClock size={12} /> {entry.readingTimeMinutes} min
|
||||
</span>
|
||||
{entry.frontmatter.draft ? (
|
||||
<span className="journal-status-pill is-draft">Draft</span>
|
||||
) : (
|
||||
<span className="journal-status-pill is-published">Published</span>
|
||||
)}
|
||||
{entry.frontmatter.password && (
|
||||
<span>
|
||||
<IconLock size={12} /> Password
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="journal-admin-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-sm admin-btn-primary"
|
||||
onClick={() => router.push(`/admin/journal/${entry.slug}`)}
|
||||
>
|
||||
Edit in Studio
|
||||
</button>
|
||||
<a
|
||||
href={`/journal/${entry.slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-sm admin-btn-secondary"
|
||||
title="View live page"
|
||||
>
|
||||
<IconLink size={14} />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-sm admin-btn-danger"
|
||||
onClick={() => handleDelete(entry.slug)}
|
||||
title="Delete entry"
|
||||
style={{ marginLeft: 'auto' }}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="journal-modal-overlay">
|
||||
<div className="journal-modal-card">
|
||||
<h3 style={{ margin: '0 0 1rem' }}>Create New Journal Entry</h3>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.85rem',
|
||||
marginBottom: '4px',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
placeholder="e.g. Expedition Nordkap"
|
||||
value={newTitle}
|
||||
onChange={(e) => {
|
||||
setNewTitle(e.target.value);
|
||||
if (!newSlug || newSlug === sanitizeSlug(newTitle)) {
|
||||
setNewSlug(sanitizeSlug(e.target.value));
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.85rem',
|
||||
marginBottom: '4px',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
URL Slug
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
placeholder="e.g. expedition-nordkap"
|
||||
value={newSlug}
|
||||
onChange={(e) => setNewSlug(sanitizeSlug(e.target.value))}
|
||||
required
|
||||
/>
|
||||
<span
|
||||
style={{ fontSize: '0.75rem', opacity: 0.6, marginTop: '2px', display: 'block' }}
|
||||
>
|
||||
Will be accessible at /journal/{newSlug || 'slug'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.85rem',
|
||||
marginBottom: '6px',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
Start from
|
||||
</label>
|
||||
<div className="journal-template-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={`journal-template-card${selectedTemplateId === null ? ' is-selected' : ''}`}
|
||||
onClick={() => setSelectedTemplateId(null)}
|
||||
>
|
||||
<span className="journal-template-card-name">Blank</span>
|
||||
<span className="journal-template-card-desc">Start with an empty editor.</span>
|
||||
</button>
|
||||
{JOURNAL_TEMPLATES.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
className={`journal-template-card${selectedTemplateId === template.id ? ' is-selected' : ''}`}
|
||||
onClick={() => setSelectedTemplateId(template.id)}
|
||||
>
|
||||
<span className="journal-template-card-name">{template.name}</span>
|
||||
<span className="journal-template-card-desc">{template.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setSelectedTemplateId(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={creating || !newTitle.trim()}
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create & Open Studio'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
app/admin/components/journal/StorySettingsModal.tsx
Normal file
188
app/admin/components/journal/StorySettingsModal.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import type { ParsedJournal } from '@/lib/journal';
|
||||
import type { AssetPickTarget } from './BlockFields';
|
||||
|
||||
type Frontmatter = ParsedJournal['frontmatter'];
|
||||
|
||||
interface StorySettingsModalProps {
|
||||
frontmatter: Frontmatter;
|
||||
onChange: (updates: Partial<Frontmatter>) => void;
|
||||
onPickAsset: (target: AssetPickTarget) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** The entry's metadata — subtitle, author, date, cover, password, draft (#555). */
|
||||
export function StorySettingsModal({
|
||||
frontmatter,
|
||||
onChange,
|
||||
onPickAsset,
|
||||
onClose,
|
||||
}: StorySettingsModalProps) {
|
||||
return (
|
||||
<div className="journal-modal-overlay">
|
||||
<div className="journal-modal-card">
|
||||
<h3 style={{ margin: '0 0 1.25rem' }}>Story Settings & Metadata</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.8rem',
|
||||
opacity: 0.8,
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Subtitle
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
value={frontmatter.subtitle || ''}
|
||||
placeholder="e.g. Field notes from our winter journey"
|
||||
onChange={(e) => onChange({ subtitle: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.8rem',
|
||||
opacity: 0.8,
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Author
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
value={frontmatter.author || ''}
|
||||
placeholder="e.g. Ralf"
|
||||
onChange={(e) => onChange({ author: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.8rem',
|
||||
opacity: 0.8,
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Publish Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="admin-input"
|
||||
value={frontmatter.date || ''}
|
||||
onChange={(e) => onChange({ date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.8rem',
|
||||
opacity: 0.8,
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Cover Photo
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '70px',
|
||||
height: '50px',
|
||||
borderRadius: '6px',
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{frontmatter.coverAssetId ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${frontmatter.coverAssetId}`}
|
||||
alt="Cover"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.75rem', opacity: 0.5 }}>None</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onPickAsset({
|
||||
title: 'Select Cover Photo',
|
||||
onSelect: (id) => onChange({ coverAssetId: id }),
|
||||
})
|
||||
}
|
||||
>
|
||||
Choose Cover
|
||||
</button>
|
||||
{frontmatter.coverAssetId && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs admin-btn-danger"
|
||||
onClick={() => onChange({ coverAssetId: undefined })}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.8rem',
|
||||
opacity: 0.8,
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Password Protection (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="admin-input"
|
||||
value={frontmatter.password || ''}
|
||||
placeholder="Leave empty for public access"
|
||||
onChange={(e) => onChange({ password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', paddingTop: '4px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="draft-checkbox"
|
||||
checked={!!frontmatter.draft}
|
||||
onChange={(e) => onChange({ draft: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="draft-checkbox" style={{ fontSize: '0.9rem', cursor: 'pointer' }}>
|
||||
Keep as Draft (Hidden from public /journal list)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1.5rem' }}>
|
||||
<button type="button" className="admin-btn admin-btn-primary" onClick={() => onClose()}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
app/admin/components/journal/blockOps.ts
Normal file
66
app/admin/components/journal/blockOps.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { JournalBlock } from '@/lib/journal';
|
||||
|
||||
/**
|
||||
* Block-list operations of the journal editor, as plain functions so they can
|
||||
* be tested without rendering the editor (#555).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Photo blocks may carry a legacy positional reference ("1", "2") instead of an
|
||||
* asset UUID. Those only ever resolved against a subpage's album; on a
|
||||
* standalone journal page there is no album, so the photo silently disappears.
|
||||
* Flag them in the editor so the author can re-pick before publishing.
|
||||
*/
|
||||
const ASSET_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isLegacyAssetRef(assetId: string): boolean {
|
||||
return assetId.length > 0 && !ASSET_UUID.test(assetId);
|
||||
}
|
||||
|
||||
/** The block the "Add Block" toolbar inserts for each type. */
|
||||
export function createBlock(type: JournalBlock['type']): JournalBlock {
|
||||
switch (type) {
|
||||
case 'heading':
|
||||
return { type: 'heading', level: 2, text: 'New Heading' };
|
||||
case 'paragraph':
|
||||
return { type: 'paragraph', html: 'Enter paragraph text here...' };
|
||||
case 'quote':
|
||||
return { type: 'quote', text: 'Enter quote text...', author: '' };
|
||||
case 'photo':
|
||||
return { type: 'photo', assetId: '', caption: '', layout: 'contained' };
|
||||
case 'photo-pair':
|
||||
return { type: 'photo-pair', assetIds: ['', ''], caption: '' };
|
||||
case 'photo-grid':
|
||||
return { type: 'photo-grid', assetIds: ['', '', ''], caption: '' };
|
||||
case 'facts':
|
||||
return {
|
||||
type: 'facts',
|
||||
items: [
|
||||
{ label: '', value: '' },
|
||||
{ label: '', value: '' },
|
||||
],
|
||||
};
|
||||
case 'map':
|
||||
return { type: 'map', caption: '', line: true, items: [] };
|
||||
case 'album':
|
||||
return { type: 'album', albumId: '', layout: 'grid' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The blocks with the one at `index` moved a step up or down, or null when it
|
||||
* is already at that end — the arrow buttons are disabled there, but a stale
|
||||
* click must not reorder anything.
|
||||
*/
|
||||
export function moveBlock(
|
||||
blocks: JournalBlock[],
|
||||
index: number,
|
||||
direction: 'up' | 'down',
|
||||
): JournalBlock[] | null {
|
||||
const target = direction === 'up' ? index - 1 : index + 1;
|
||||
if (target < 0 || target >= blocks.length) return null;
|
||||
const next = [...blocks];
|
||||
const [moved] = next.splice(index, 1);
|
||||
next.splice(target, 0, moved);
|
||||
return next;
|
||||
}
|
||||
676
app/admin/components/journal/journal-studio.css
Normal file
676
app/admin/components/journal/journal-studio.css
Normal file
@@ -0,0 +1,676 @@
|
||||
/* ── Journal Studio & Editor CSS ──────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* The studio is a full-width workspace: it breaks out of the 1120px
|
||||
* `.admin-main` column so the split view has room for editor *and* preview.
|
||||
*/
|
||||
.admin-main:has(> .journal-studio),
|
||||
.admin-main:has(> .journal-editor-container) {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Form controls ─────────────────────────────────────────────────
|
||||
* Local field styling for the studio. Scoped so the generic class name
|
||||
* cannot leak into the rest of the admin panel.
|
||||
*/
|
||||
.journal-studio .admin-input,
|
||||
.journal-editor-container .admin-input,
|
||||
.journal-modal-card .admin-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.7rem;
|
||||
background: var(--admin-bg-input, #161616);
|
||||
border: 1px solid var(--admin-border, #2e2e2e);
|
||||
border-radius: 8px;
|
||||
color: var(--admin-text, #f0f0f0);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
.journal-studio .admin-input:hover,
|
||||
.journal-editor-container .admin-input:hover,
|
||||
.journal-modal-card .admin-input:hover {
|
||||
border-color: var(--admin-border-hover, #444);
|
||||
}
|
||||
|
||||
.journal-studio .admin-input:focus-visible,
|
||||
.journal-editor-container .admin-input:focus-visible,
|
||||
.journal-modal-card .admin-input:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--admin-border-focus, #6366f1);
|
||||
}
|
||||
|
||||
.journal-studio .admin-input::placeholder,
|
||||
.journal-editor-container .admin-input::placeholder,
|
||||
.journal-modal-card .admin-input::placeholder {
|
||||
color: var(--admin-text-secondary, #a0a0a0);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.journal-editor-container textarea.admin-input,
|
||||
.journal-modal-card textarea.admin-input {
|
||||
resize: vertical;
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
.journal-editor-container select.admin-input {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
padding-right: 2rem;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, currentColor 50%),
|
||||
linear-gradient(135deg, currentColor 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 16px) calc(50% + 1px),
|
||||
calc(100% - 11px) calc(50% + 1px);
|
||||
background-size: 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.journal-level-select {
|
||||
width: 5.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.journal-studio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 70px);
|
||||
}
|
||||
|
||||
.journal-studio-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid var(--admin-border, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.journal-studio-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.journal-list-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.journal-admin-card {
|
||||
background: var(--admin-surface, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--admin-border, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.journal-admin-card:hover {
|
||||
border-color: var(--admin-accent, #6366f1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.journal-admin-card-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.journal-admin-card-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.journal-admin-card-body {
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.journal-admin-card-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
|
||||
.journal-admin-card-slug {
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.journal-admin-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.journal-admin-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--admin-border, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
/* ── Split-Screen Studio Editor ────────────────────────────────────── */
|
||||
|
||||
.journal-editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 65px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.journal-editor-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--admin-surface, #18181b);
|
||||
border-bottom: 1px solid var(--admin-border, rgba(255, 255, 255, 0.08));
|
||||
gap: 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.journal-editor-topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.journal-editor-title-input {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: inherit;
|
||||
padding: 4px 8px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.journal-editor-title-input:focus {
|
||||
border-color: var(--admin-accent, #6366f1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.journal-editor-topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.journal-editor-split {
|
||||
display: grid;
|
||||
/* Left width is driven by the draggable divider; the preview takes the rest. */
|
||||
grid-template-columns: var(--journal-split-left, 46%) 7px minmax(0, 1fr);
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Kill text selection and pointer flicker while the divider is being dragged. */
|
||||
.journal-editor-split.is-dragging {
|
||||
user-select: none;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.journal-editor-split.is-dragging .journal-editor-pane-right {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.journal-editor-resizer {
|
||||
position: relative;
|
||||
cursor: col-resize;
|
||||
background: var(--admin-border, rgba(255, 255, 255, 0.08));
|
||||
transition: background 0.15s ease;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.journal-editor-resizer:hover,
|
||||
.journal-editor-resizer:focus-visible,
|
||||
.journal-editor-split.is-dragging .journal-editor-resizer {
|
||||
background: var(--admin-accent, #6366f1);
|
||||
}
|
||||
|
||||
/* The resizer used to change colour on focus exactly as it does on hover,
|
||||
so from the keyboard there was no telling whether it had the focus or the
|
||||
mouse was merely resting on it. */
|
||||
.journal-editor-resizer:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Grip dots, and a widened invisible hit area so the divider is easy to grab. */
|
||||
.journal-editor-resizer-grip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 15px;
|
||||
height: 44px;
|
||||
border-radius: 4px;
|
||||
background: repeating-linear-gradient(to bottom, currentColor 0 2px, transparent 2px 5px);
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
background-clip: content-box;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.journal-editor-resizer:hover .journal-editor-resizer-grip {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.journal-editor-pane-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem 2rem 4rem;
|
||||
background: var(--admin-bg, #09090b);
|
||||
}
|
||||
|
||||
.journal-editor-pane-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
background: var(--admin-bg, #111);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.journal-preview-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-size: 0.75rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.journal-preview-frame {
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
transition: max-width 0.3s ease;
|
||||
}
|
||||
|
||||
/*
|
||||
* The essay's full-bleed and wide figures are sized against the viewport on the
|
||||
* live site. Inside the preview pane that overshoots — the photo would bleed out
|
||||
* of the pane and drift as the split is dragged — so they are re-anchored to the
|
||||
* frame. 100% is the container's content box, so reaching the pane edges means
|
||||
* adding back both horizontal paddings: 4rem for this frame plus 3rem for
|
||||
* .essay-container. Keep in sync if either padding changes.
|
||||
*/
|
||||
.journal-preview-frame .essay-container {
|
||||
--essay-bleed: calc(100% + 7rem);
|
||||
--essay-wide: 100%;
|
||||
}
|
||||
|
||||
.journal-preview-frame.mobile {
|
||||
max-width: 380px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
min-height: 100%;
|
||||
background: var(--background, #000);
|
||||
}
|
||||
|
||||
.journal-raw-markdown-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: #f4f4f5;
|
||||
border: 1px solid var(--admin-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.journal-raw-markdown-editor:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--admin-accent, #6366f1);
|
||||
}
|
||||
|
||||
/* Modal styling for Entry creation / Metadata */
|
||||
.journal-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.journal-modal-card {
|
||||
background: var(--admin-surface, #18181b);
|
||||
border: 1px solid var(--admin-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 620px;
|
||||
max-height: 88vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.75rem;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* ── Template picker (Create modal) ──────────────────────────────── */
|
||||
|
||||
.journal-template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.journal-template-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.7rem;
|
||||
background: var(--admin-bg-input, #161616);
|
||||
/* <button> does not inherit color or font from its parent — without these
|
||||
the cards render in the UA default, black on the dark admin surface. */
|
||||
color: var(--admin-text, #f0f0f0);
|
||||
font: inherit;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.journal-template-card:hover {
|
||||
border-color: var(--admin-border-hover, #444);
|
||||
}
|
||||
|
||||
.journal-template-card.is-selected {
|
||||
border-color: var(--admin-accent, #6366f1);
|
||||
}
|
||||
|
||||
.journal-template-card-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.journal-template-card-desc {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* ── Photo grid block (editor tiles) ─────────────────────────────── */
|
||||
|
||||
.journal-grid-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.journal-grid-tile {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.journal-grid-tile-pick {
|
||||
height: 80px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.journal-grid-tile-pick img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.journal-grid-tile-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
/* ── Facts block (editor rows) ───────────────────────────────────── */
|
||||
|
||||
.journal-facts-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Album block (editor) ────────────────────────────────────────── */
|
||||
|
||||
.journal-album-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.journal-album-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.85;
|
||||
flex: 1 1 120px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.journal-album-field .admin-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Map block (editor pin rows) ─────────────────────────────────── */
|
||||
|
||||
.journal-map-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.journal-map-row .admin-input {
|
||||
flex: 1 1 140px;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.journal-map-row .journal-map-coord {
|
||||
flex: 0 1 110px;
|
||||
}
|
||||
|
||||
.journal-map-index {
|
||||
width: 1.4rem;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
text-align: right;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.journal-map-photo {
|
||||
width: 96px;
|
||||
height: 64px;
|
||||
flex: none;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.journal-map-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.journal-map-all {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.journal-map-line-toggle {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Card meta & status ───────────────────────────────────────────── */
|
||||
|
||||
.journal-admin-card-meta span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.journal-card-cover-placeholder {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.journal-status-pill {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.journal-status-pill.is-draft {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #eab308;
|
||||
border: 1px solid rgba(234, 179, 8, 0.3);
|
||||
}
|
||||
|
||||
.journal-status-pill.is-published {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.journal-preview-bar-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Block field rows ─────────────────────────────────────────────
|
||||
* The generic `.admin-input { width: 100% }` would blow these rows apart,
|
||||
* so the fixed-width controls are pinned here (higher specificity wins).
|
||||
*/
|
||||
.journal-heading-row,
|
||||
.journal-photo-layout-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.journal-editor-container .journal-heading-row .journal-level-select {
|
||||
flex: 0 0 5.5rem;
|
||||
width: 5.5rem;
|
||||
}
|
||||
|
||||
.journal-editor-container .journal-heading-row input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.journal-editor-container .journal-photo-layout-row select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.journal-photo-layout-row .admin-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Stack the split view on narrow screens instead of squeezing both panes. */
|
||||
@media (max-width: 1100px) {
|
||||
.journal-editor-split {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.journal-editor-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.journal-editor-pane-left {
|
||||
border-bottom: 1px solid var(--admin-border, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
}
|
||||
|
||||
/* An unresolvable photo reference — shown in the block editor, not to visitors. */
|
||||
.journal-block-warning {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
color: #fca5a5;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
109
app/admin/components/journal/splitPane.ts
Normal file
109
app/admin/components/journal/splitPane.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
/**
|
||||
* The draggable divider between the journal editor's authoring pane and its
|
||||
* preview (#555). The arithmetic is plain functions; `useSplitPane` owns the
|
||||
* state, the drag listeners and persistence.
|
||||
*/
|
||||
|
||||
/** Width of the authoring pane, in percent of the split view. */
|
||||
export const SPLIT_STORAGE_KEY = 'folio-journal-split';
|
||||
export const SPLIT_MIN = 25;
|
||||
export const SPLIT_MAX = 70;
|
||||
export const SPLIT_DEFAULT = 46;
|
||||
|
||||
export function clampSplit(pct: number): number {
|
||||
return Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, pct));
|
||||
}
|
||||
|
||||
/** The width for a pointer at `clientX` over a split view at `left`/`width`. */
|
||||
export function splitFromPointer(clientX: number, left: number, width: number): number | null {
|
||||
if (width === 0) return null;
|
||||
return clampSplit(((clientX - left) / width) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard access for the divider: arrows nudge (Shift for bigger steps), Home
|
||||
* resets. Null for any other key, which the divider then leaves alone.
|
||||
*/
|
||||
export function splitAfterKey(pct: number, key: string, shiftKey: boolean): number | null {
|
||||
const step = shiftKey ? 10 : 2;
|
||||
if (key === 'ArrowLeft') return Math.max(SPLIT_MIN, pct - step);
|
||||
if (key === 'ArrowRight') return Math.min(SPLIT_MAX, pct + step);
|
||||
if (key === 'Home') return SPLIT_DEFAULT;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A stored width, or null when it is missing or out of range. */
|
||||
export function parseStoredSplit(raw: string | null): number | null {
|
||||
const stored = Number(raw);
|
||||
return stored >= SPLIT_MIN && stored <= SPLIT_MAX ? stored : null;
|
||||
}
|
||||
|
||||
export function useSplitPane() {
|
||||
const splitRef = useRef<HTMLDivElement>(null);
|
||||
const [splitPct, setSplitPct] = useState(SPLIT_DEFAULT);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
// Restore the last width after mount (localStorage is unavailable on the server,
|
||||
// and reading it in the initial state would not match the server render).
|
||||
useEffect(() => {
|
||||
const stored = parseStoredSplit(window.localStorage.getItem(SPLIT_STORAGE_KEY));
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (stored !== null) setSplitPct(stored);
|
||||
}, []);
|
||||
|
||||
const applySplit = useCallback((clientX: number) => {
|
||||
const rect = splitRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const pct = splitFromPointer(clientX, rect.left, rect.width);
|
||||
if (pct !== null) setSplitPct(pct);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
applySplit(e.clientX);
|
||||
};
|
||||
const onUp = () => setDragging(false);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [dragging, applySplit]);
|
||||
|
||||
// Persist once the drag ends, not on every pixel.
|
||||
useEffect(() => {
|
||||
if (dragging) return;
|
||||
window.localStorage.setItem(SPLIT_STORAGE_KEY, String(Math.round(splitPct)));
|
||||
}, [dragging, splitPct]);
|
||||
|
||||
const onResizerMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const onResizerDoubleClick = () => setSplitPct(SPLIT_DEFAULT);
|
||||
|
||||
const onResizerKeyDown = (e: React.KeyboardEvent) => {
|
||||
const next = splitAfterKey(splitPct, e.key, e.shiftKey);
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
setSplitPct(next);
|
||||
};
|
||||
|
||||
return {
|
||||
splitRef,
|
||||
splitPct,
|
||||
dragging,
|
||||
onResizerMouseDown,
|
||||
onResizerDoubleClick,
|
||||
onResizerKeyDown,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user