import { useEffect, useRef, useState } from 'react' import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { api, apiBlob, apiDownload, apiUpload } from '../api.js' import CallTrace from '../components/CallTrace.jsx' import JournalDocumentEditor from '../components/JournalDocumentEditor.jsx' import RunLogPopup from '../components/RunLogPopup.jsx' import { useAuth } from '../context/AuthContext.jsx' import { useUnsavedChanges } from '../context/UnsavedChanges.jsx' import { entryTitle, formatWhen, originLabel } from '../journal/document.js' function navTrace(state) { return state?.trace || null } function navLog(state) { if (Array.isArray(state?.run_log) && state.run_log.length) return state.run_log const log = state?.trace?.log return Array.isArray(log) ? log : [] } function downloadLocalJson(payload, filename) { const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = filename document.body.appendChild(link) link.click() link.remove() URL.revokeObjectURL(url) } export default function JournalEditorPage() { const { spaceId, dayId } = useParams() const [params] = useSearchParams() const location = useLocation() const entryQuery = params.get('entry') || '' const wantDraft = params.get('draft') === '1' const { session, isAdmin } = useAuth() const navigate = useNavigate() const editorRef = useRef(null) const entryIdRef = useRef(entryQuery) const skipHydrate = useRef(false) const [day, setDay] = useState(null) const [entryId, setEntryId] = useState(entryQuery) const [title, setTitle] = useState('') const [body, setBody] = useState('') const [contentKey, setContentKey] = useState(0) const [versions, setVersions] = useState([]) const [media, setMedia] = useState([]) const [previews, setPreviews] = useState({}) const [error, setError] = useState('') const [notice, setNotice] = useState('') const [busy, setBusy] = useState(false) const [savedEntryId, setSavedEntryId] = useState('') const [currentVersionId, setCurrentVersionId] = useState('') const { dirty, setDirty } = useUnsavedChanges() const savedRef = useRef({ title: '', body: '' }) const [logOpen, setLogOpen] = useState(false) const [generationSummary, setGenerationSummary] = useState('') const [trace, setTrace] = useState(() => navTrace(location.state)) const [runLog, setRunLog] = useState(() => navLog(location.state)) const [debugOn, setDebugOn] = useState(false) const markClean = (nextTitle, nextBody) => { const titleValue = entryTitle(nextTitle || '') const bodyValue = nextBody || '' savedRef.current = { title: titleValue, body: bodyValue } setDirty(false) } const noteDirty = (nextTitle, nextBody) => { const saved = savedRef.current setDirty(entryTitle(nextTitle || '') !== saved.title || (nextBody || '') !== saved.body) } const showLoaded = (entry, draft) => { setContentKey((n) => n + 1) if (entry) { setEntryId(entry.id) entryIdRef.current = entry.id setTitle(entryTitle(entry.title || '')) setBody(entry.body || '') setVersions(entry.versions || []) setMedia(entry.media || []) setCurrentVersionId(entry.current_version_id || entry.versions?.at(-1)?.id || '') setGenerationSummary('') markClean(entry.title || '', entry.body || '') return } setEntryId('') entryIdRef.current = '' setTitle(entryTitle(draft?.title || '')) setBody(draft?.body || '') setVersions([]) setMedia([]) setSavedEntryId('') setCurrentVersionId('') setGenerationSummary(draft?.generation_summary || '') markClean(draft?.title || '', draft?.body || '') } const hydrate = async () => { const payload = await api(`/api/journal/days/${dayId}`, { token: session.token }) setDay(payload) if (wantDraft) { showLoaded(null, payload.current_draft) const existing = payload.entries?.at(-1) if (existing?.id) { setSavedEntryId(existing.id) const entry = await api(`/api/journal/entries/${existing.id}`, { token: session.token }) setVersions(entry.versions || []) setCurrentVersionId(entry.current_version_id || entry.versions?.at(-1)?.id || '') } return } const wanted = entryQuery || payload.entries.at(-1)?.id if (wanted) { const entry = await api(`/api/journal/entries/${wanted}`, { token: session.token }) showLoaded(entry) setSavedEntryId(entry.id) } else { showLoaded(null, payload.current_draft) } } useEffect(() => { if (skipHydrate.current) { skipHydrate.current = false return } hydrate().catch((err) => setError(err.message)) }, [session.token, dayId, entryQuery, wantDraft]) useEffect(() => { const nextTrace = navTrace(location.state) const nextLog = navLog(location.state) if (nextTrace) setTrace(nextTrace) if (nextLog.length) setRunLog(nextLog) }, [location.state]) useEffect(() => { if (!isAdmin) return undefined let cancelled = false api('/api/admin/debug', { token: session.token }) .then((data) => { if (!cancelled) setDebugOn(Boolean(data.persist_enabled)) }) .catch(() => { if (!cancelled) setDebugOn(false) }) api( `/api/admin/debug/latest?journal_day_id=${encodeURIComponent(dayId)}&purpose=journal_generate`, { token: session.token } ) .then((payload) => { const run = payload?.run if (cancelled || !run) return const stored = run.trace || run.payload?.trace || null const log = run.run_log || run.payload?.run_log || stored?.log || [] setTrace((current) => current || stored) setRunLog((current) => (current.length ? current : (Array.isArray(log) ? log : []))) }) .catch(() => {}) return () => { cancelled = true } }, [session.token, dayId, isAdmin]) useEffect(() => () => setDirty(false), [setDirty]) useEffect(() => { let cancelled = false const urls = {} Promise.all( media.map(async (item) => { const url = await apiBlob(`/api/journal/media/${item.id}`, { token: session.token }) urls[item.id] = url }) ).then(() => { if (!cancelled) setPreviews(urls) }).catch(() => {}) return () => { cancelled = true Object.values(urls).forEach((url) => URL.revokeObjectURL(url)) } }, [media, session.token]) const downloadDebug = async () => { setError('') try { if (debugOn) { await apiDownload( `/api/admin/debug/export?format=json&journal_day_id=${encodeURIComponent(dayId)}&purpose=journal_generate`, { token: session.token } ) return } if (!trace && !runLog.length) return downloadLocalJson( { kind: 'kansho.debug_export', version: 2, scope: 'journal_generate', journal_day_id: dayId, note: 'Lokale Kopie der Journal-Testspur dieses Entwurfs. Mapping-Tabelle ist ausgeschlossen.', run_count: 1, runs: [{ purpose: 'journal_generate', trace, run_log: runLog }] }, 'kansho-debug-journal.json' ) } catch (err) { setError(err.message) } } const persist = async (nextTitle, nextBody, nextEntryId = entryIdRef.current, origin) => { const saved = await api('/api/journal/entries', { token: session.token, method: 'POST', body: { journal_day_id: dayId, entry_id: nextEntryId || undefined, title: entryTitle(nextTitle), body: nextBody, origin: origin || (nextEntryId ? 'user_edit' : (day?.current_draft ? 'accepted_draft' : 'user_edit')), source_conversation_ids: day?.current_draft?.source_conversation_ids || [] } }) entryIdRef.current = saved.id setEntryId(saved.id) setTitle(entryTitle(saved.title || '')) setBody(saved.body || '') const fresh = await api(`/api/journal/entries/${saved.id}`, { token: session.token }) setVersions(fresh.versions || []) setMedia(fresh.media || []) setCurrentVersionId(fresh.current_version_id || fresh.versions?.at(-1)?.id || '') setSavedEntryId(saved.id) markClean(saved.title || '', saved.body || '') if (entryQuery !== saved.id) { skipHydrate.current = true navigate(`/journal/${spaceId}/${dayId}/entry?entry=${saved.id}`, { replace: true }) } return saved } const currentMarkdown = () => { if (editorRef.current?.getMarkdown) return editorRef.current.getMarkdown() return body } const save = async (event) => { event.preventDefault() setBusy(true) setError('') setNotice('') try { const nextBody = currentMarkdown() if (wantDraft && savedEntryId) { await persist(title, nextBody, savedEntryId, 'accepted_draft') setNotice('Als neue Version des Eintrags übernommen.') } else { await persist(title, nextBody) setNotice('Gespeichert.') } } catch (err) { setError(err.message) } finally { setBusy(false) } } const saveAsNewEntry = async () => { setBusy(true) setError('') setNotice('') try { await persist(title, currentMarkdown(), '', 'accepted_draft') setNotice('Als eigenen Eintrag gespeichert.') } catch (err) { setError(err.message) } finally { setBusy(false) } } const revertChanges = () => { const saved = savedRef.current setTitle(saved.title) setBody(saved.body) setContentKey((n) => n + 1) setDirty(false) setNotice('Änderungen verworfen.') } const restore = async (versionId) => { const targetId = entryId || savedEntryId if (!targetId) return if (versionId === (currentVersionId || versions.at(-1)?.id)) return if (dirty && !window.confirm('Ungespeicherte Änderungen gehen verloren. Diese Version wiederherstellen?')) return setError('') setNotice('') try { const saved = await api(`/api/journal/entries/${targetId}/restore`, { token: session.token, method: 'POST', body: { version_id: versionId } }) const fresh = await api(`/api/journal/entries/${targetId}`, { token: session.token }) showLoaded({ ...fresh, title: saved.title, body: saved.body }) setSavedEntryId(targetId) skipHydrate.current = true navigate(`/journal/${spaceId}/${dayId}/entry?entry=${targetId}`, { replace: true }) setNotice('Version wiederhergestellt.') } catch (err) { setError(err.message) } } const remove = async () => { if (!entryId) return if (!window.confirm('Eintrag in den Papierkorb legen? Das ist noch nicht endgültig. Endgültiges Löschen geschieht später im Papierkorb.')) return setError('') setNotice('') try { await api(`/api/journal/entries/${entryId}`, { token: session.token, method: 'DELETE' }) showLoaded(null) navigate(`/journal/${spaceId}/trash`) } catch (err) { setError(err.message) } } const upload = async (file) => { let currentId = entryIdRef.current if (!currentId) { const saved = await persist(title, currentMarkdown()) currentId = saved.id } const asset = await apiUpload(`/api/journal/entries/${currentId}/media`, { token: session.token, file }) const fresh = await api(`/api/journal/entries/${currentId}`, { token: session.token }) setMedia(fresh.media || []) return asset } const removeMedia = async (mediaId) => { await api(`/api/journal/media/${mediaId}`, { token: session.token, method: 'DELETE' }) setMedia((current) => current.filter((item) => item.id !== mediaId)) } return (

Zurück zum Tag {day?.day?.calendar_date ? ` · ${day.day.calendar_date}` : ''} {isAdmin && (trace || runLog.length > 0) && ( <> {' · '} )}

{wantDraft ? 'Neuer Entwurf' : 'Journal Editor'}{dirty ? ' · ungespeichert' : ''}

{wantDraft ? 'Vorschlag aus dem Gespräch. Speichern legt eine neue Version des bestehenden Eintrags an; die vorigen Fassungen bleiben in der Liste.' : 'Redigieren als Fließtext: Absätze und Markierungen formatieren, Bilder und Videos an die Schreibmarke setzen und verschieben. Gespeichert als Markdown. Eine neue Generierung überschreibt diese Fassung nicht still.'}

{error &&

{error}

} {notice &&

{notice}

} {generationSummary &&

{generationSummary}

}
{ setTitle(e.target.value) noteDirty(e.target.value, currentMarkdown()) }} placeholder="Überschrift" /> { setBody(next) noteDirty(title, next) }} media={media} previews={previews} onUpload={async (file) => { setError('') setBusy(true) try { return await upload(file) } catch (err) { setError(err.message) return null } finally { setBusy(false) } }} onPlaced={async () => { setError('') setBusy(true) try { await persist(title, currentMarkdown()) setNotice('Gespeichert.') } catch (err) { setError(err.message) } finally { setBusy(false) } }} onRemoveMedia={removeMedia} disabled={busy} />
{dirty && versions.length === 0 && ( )} {wantDraft && savedEntryId && ( )} {entryId && ( )}
{isAdmin && trace && !logOpen && (

Verlauf

)} {versions.length > 0 && (

Versionen

    {versions.map((item) => { const isCurrent = item.id === (currentVersionId || versions.at(-1)?.id) return (
  • {entryTitle(item.title) || originLabel(item.origin)} {' · '} {originLabel(item.origin)} {' · '} {formatWhen(item.created)} {isCurrent ? ' · Aktuell' : ''} {isCurrent ? ( dirty ? ( ) : null ) : ( )}
  • ) })}
)} setLogOpen(false)} />
) }