'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { useRouter } from 'next/navigation'; import type { JournalEntrySummary, ParsedJournal, JournalBlock } from '@/lib/journal'; import { parseJournalMarkdown, serializeJournalMarkdown, sanitizeSlug } from '@/lib/journal'; import { IconFileText, IconSparkles, IconQuote, IconCamera, IconArrowLeftRight, IconTrash, IconChevronUp, IconChevronDown, IconGear, IconLink, IconPlus, IconBook, IconEye, IconClock, IconCalendar, IconLock, IconCheck, } from './Icons'; import AssetPicker from './AssetPicker'; import { BlockBadge } from './BlockBadge'; import { EssayView } from '@/app/[...path]/EssayView'; import type { PhotoItem } from '@/app/[...path]/PhotoGrid'; import './journal-studio.css'; interface JournalStudioProps { /** Entry to open, taken from the /admin/journal/[slug] route. */ slug?: string; } export function JournalStudio({ slug: activeSlug }: JournalStudioProps) { const router = useRouter(); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // New Entry Modal const [showCreateModal, setShowCreateModal] = useState(false); const [newTitle, setNewTitle] = useState(''); const [newSlug, setNewSlug] = useState(''); const [creating, setCreating] = useState(false); 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 res = await fetch('/api/admin/journal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: newTitle.trim(), slug, }), }); if (res.ok) { const data = await res.json(); setShowCreateModal(false); setNewTitle(''); setNewSlug(''); 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(`Are you sure you want to delete "${slug}"?`)) 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 router.push('/admin/journal')} />; } return (

Journal & Photo Essays

Author visual stories, field notes, and longform photo essays with live preview.

{loading ? (
Loading journal entries...
) : error ? (
{error}
) : entries.length === 0 ? (

No journal entries yet

Create your first story to share travel journals or wedding reportages.

) : (
{entries.map((entry) => (
{entry.frontmatter.coverAssetId ? ( // eslint-disable-next-line @next/next/no-img-element {entry.frontmatter.title ) : ( )}

{entry.frontmatter.title || entry.slug}

/journal/{entry.slug}
{entry.frontmatter.date && ( {entry.frontmatter.date} )} {entry.readingTimeMinutes} min {entry.frontmatter.draft ? ( Draft ) : ( Published )} {entry.frontmatter.password && ( Password )}
))}
)} {/* Create Modal */} {showCreateModal && (

Create New Journal Entry

{ setNewTitle(e.target.value); if (!newSlug || newSlug === sanitizeSlug(newTitle)) { setNewSlug(sanitizeSlug(e.target.value)); } }} autoFocus required />
setNewSlug(sanitizeSlug(e.target.value))} required /> Will be accessible at /journal/{newSlug || 'slug'}
)}
); } // ── Studio Editor Component ────────────────────────────────────────── interface JournalEditorProps { slug: string; onBack: () => void; } /** * 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; function isLegacyAssetRef(assetId: string): boolean { return assetId.length > 0 && !ASSET_UUID.test(assetId); } /** Width of the authoring pane, in percent of the split view. */ const SPLIT_STORAGE_KEY = 'folio-journal-split'; const SPLIT_MIN = 25; const SPLIT_MAX = 70; const SPLIT_DEFAULT = 46; function JournalEditor({ slug, onBack }: JournalEditorProps) { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false); const [editorMode, setEditorMode] = useState<'blocks' | 'markdown'>('blocks'); const [viewport, setViewport] = useState<'desktop' | 'mobile'>('desktop'); // Draggable divider between authoring pane and preview. const splitRef = useRef(null); const [splitPct, setSplitPct] = useState(SPLIT_DEFAULT); const [dragging, setDragging] = useState(false); // Restore the last width after mount (localStorage is unavailable on the server). useEffect(() => { const stored = Number(window.localStorage.getItem(SPLIT_STORAGE_KEY)); if (stored >= SPLIT_MIN && stored <= SPLIT_MAX) setSplitPct(stored); }, []); const applySplit = useCallback((clientX: number) => { const rect = splitRef.current?.getBoundingClientRect(); if (!rect || rect.width === 0) return; const pct = ((clientX - rect.left) / rect.width) * 100; setSplitPct(Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, 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]); // Keyboard access for the divider: arrows nudge, Home resets. const handleSplitKeyDown = (e: React.KeyboardEvent) => { const step = e.shiftKey ? 10 : 2; if (e.key === 'ArrowLeft') { e.preventDefault(); setSplitPct((p) => Math.max(SPLIT_MIN, p - step)); } else if (e.key === 'ArrowRight') { e.preventDefault(); setSplitPct((p) => Math.min(SPLIT_MAX, p + step)); } else if (e.key === 'Home') { e.preventDefault(); setSplitPct(SPLIT_DEFAULT); } }; const [rawMarkdown, setRawMarkdown] = useState(''); const [parsed, setParsed] = useState(() => ({ frontmatter: {}, blocks: [], referencedAssetIds: [], })); // Settings / Metadata Modal const [showMetaModal, setShowMetaModal] = useState(false); /** Real width/height ratios of the referenced photos, measured from thumbnails. */ const [assetRatios, setAssetRatios] = useState>({}); // Asset Picker State const [assetPickerTarget, setAssetPickerTarget] = useState<{ onSelect: (assetId: string) => void; title: string; } | null>(null); // Load entry useEffect(() => { async function load() { setLoading(true); try { const res = await fetch(`/api/admin/journal/${slug}`); if (res.ok) { const data = await res.json(); const md = data.entry.rawMarkdown; setRawMarkdown(md); setParsed(parseJournalMarkdown(md)); setDirty(false); } else { alert('Failed to load journal entry'); } } catch { alert('Error loading journal entry'); } finally { setLoading(false); } } load(); }, [slug]); // 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: Array.from( new Set( newBlocks.flatMap((b) => { if (b.type === 'photo') return [b.assetId]; if (b.type === 'photo-pair') return b.assetIds; return []; }), ), ), }; 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 () => { 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) { setDirty(false); } else { const data = await res.json(); alert(data.error || 'Failed to save'); } } catch { alert('Error saving entry'); } finally { setSaving(false); } }; // 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 /* * 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()); useEffect(() => { let cancelled = false; for (const id of parsed.referencedAssetIds) { 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; }; }, [parsed.referencedAssetIds]); // Block manipulation const handleAddBlock = (type: JournalBlock['type']) => { let newBlock: JournalBlock; switch (type) { case 'heading': newBlock = { type: 'heading', level: 2, text: 'New Heading' }; break; case 'paragraph': newBlock = { type: 'paragraph', html: 'Enter paragraph text here...' }; break; case 'quote': newBlock = { type: 'quote', text: 'Enter quote text...', author: '' }; break; case 'photo': newBlock = { type: 'photo', assetId: '', caption: '', layout: 'contained' }; break; case 'photo-pair': newBlock = { type: 'photo-pair', assetIds: ['', ''], caption: '' }; break; } handleBlocksChange([...parsed.blocks, newBlock]); }; const handleMoveBlock = (index: number, direction: 'up' | 'down') => { const target = direction === 'up' ? index - 1 : index + 1; if (target < 0 || target >= parsed.blocks.length) return; const blocks = [...parsed.blocks]; const [moved] = blocks.splice(index, 1); blocks.splice(target, 0, moved); handleBlocksChange(blocks); }; 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); }; // Mock PhotoItems for preview const previewAssets: PhotoItem[] = parsed.referencedAssetIds.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, })); if (loading) { return (
Opening Journal Studio...
); } return (
{/* Top Bar */}
handleFrontmatterChange({ title: e.target.value })} />
Live
{/* Split Screen */}
{/* Left: Authoring Pane */}
{editorMode === 'markdown' ? (