'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 { useContentRestored } from '../contentRestored'; 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'; import { useNotify } from '../Notifications'; interface JournalEditorProps { slug: string; mapEnabled?: boolean; onBack: () => void; } export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps) { const notify = useNotify(); 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(() => ({ frontmatter: {}, blocks: [], referencedAssetIds: [], })); // Settings / Metadata Modal const [showMetaModal, setShowMetaModal] = useState(false); // Asset Picker State const [assetPickerTarget, setAssetPickerTarget] = useState(null); /** Set when the entry could not be fetched; blocks saving over it. */ const [loadError, setLoadError] = useState(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(`journal-${slug}`, rawMarkdown, dirty); const loadDraft = draft.load; const serverMarkdown = useRef(''); // Bumped when this entry is restored from a backup, to load it again. const [reloadKey, setReloadKey] = useState(0); useContentRestored(({ target, slug: restored }) => { if (target === 'journal' && restored === slug) setReloadKey((k) => k + 1); }); // 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, reloadKey]); // 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) => { 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; // Nothing to save: every save rotates a backup, so repeated Cmd+S on an // unchanged entry pushed real history out of the ten kept per file. if (!dirty || saving) 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) { // The file as written, not as sent: a password line is hashed on the // way to disk (#690). Taking the sent text as the base would make the // next draft look like a conflicting edit from elsewhere. const data = await res.json().catch(() => null); const written: string = typeof data?.entry?.rawMarkdown === 'string' ? data.entry.rawMarkdown : rawMarkdown; serverMarkdown.current = written; draft.saved(written); if (written !== rawMarkdown) { setRawMarkdown(written); setParsed(parseJournalMarkdown(written)); } setDirty(false); } else if (!reportIfSessionExpired(res)) { const data = await res.json(); notify('error', data.error || 'Failed to save'); } } catch { notify('error', 'Could not save the entry. Check the connection and try again.'); } 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>({}); const albumRequested = useRef(new Set()); const [albumList, setAlbumList] = useState[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 (
Opening Journal Studio...
); } /** * Replaces the editor rather than sitting above it: an empty editor saved * over the entry would replace the text with nothing. */ if (loadError) { return (
This entry could not be loaded. {loadError}

Nothing has been changed. Saving stays disabled until it loads, so an empty editor cannot overwrite the entry.

); } return (
{/* Top Bar */}
handleFrontmatterChange({ title: e.target.value })} />
Live
{/* Split Screen */}
{/* Left: Authoring Pane */}
{editorMode === 'markdown' ? (