release: v0.17.0 (#677)

See CHANGELOG.md, section [0.17.0] — 2026-09-23.
This commit is contained in:
Ralksta
2026-09-23 12:09:08 +02:00
committed by GitHub
parent a3a6319c29
commit 89a8fac564
78 changed files with 9485 additions and 2050 deletions

View 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 &quot;{block.assetId}&quot; 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 &quot;{block.assetIds.filter(isLegacyAssetRef).join('", "')}
&quot; 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 &quot;{block.assetIds.filter(isLegacyAssetRef).join('", "')}
&quot; 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&apos;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&apos;s GPS, under the album&apos;s location precision.
</span>
</div>
)}
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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 &amp; 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>
);
}

View 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 &amp; 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>
);
}

View 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;
}

View 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);
}

View 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,
};
}