Kansho/frontend/src/pages/JournalEditorPage.jsx
2026-08-29 11:04:34 +02:00

495 lines
17 KiB
JavaScript

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 (
<section className="card journal-editor-page">
<p className="muted">
<Link to={`/journal/${spaceId}/${dayId}`}>Zurück zum Tag</Link>
{day?.day?.calendar_date ? ` · ${day.day.calendar_date}` : ''}
{isAdmin && (trace || runLog.length > 0) && (
<>
{' · '}
<button type="button" className="ghost" onClick={() => setLogOpen(true)}>
Ablauf anzeigen
</button>
<button type="button" className="ghost" onClick={downloadDebug} disabled={busy}>
Debug herunterladen
</button>
</>
)}
</p>
<h1>{wantDraft ? 'Neuer Entwurf' : 'Journal Editor'}{dirty ? ' · ungespeichert' : ''}</h1>
<p className="lede">
{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.'}
</p>
{error && <p className="error">{error}</p>}
{notice && <p className="muted">{notice}</p>}
{generationSummary && <p className="muted generation-snapshot">{generationSummary}</p>}
<form className="stack" onSubmit={save}>
<input
value={title}
onChange={(e) => {
setTitle(e.target.value)
noteDirty(e.target.value, currentMarkdown())
}}
placeholder="Überschrift"
/>
<JournalDocumentEditor
ref={editorRef}
body={body}
contentKey={contentKey}
onChange={(next) => {
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}
/>
<div className="row-actions">
<button type="submit" disabled={busy}>
{busy ? 'Speichert …' : (wantDraft && savedEntryId ? 'Als neue Version speichern' : 'Speichern')}
</button>
{dirty && versions.length === 0 && (
<button type="button" className="ghost" onClick={revertChanges} disabled={busy}>
Alle Änderungen rückgängig machen
</button>
)}
{wantDraft && savedEntryId && (
<button type="button" className="ghost" onClick={saveAsNewEntry} disabled={busy}>
Als eigenen Eintrag speichern
</button>
)}
{entryId && (
<button type="button" className="ghost" onClick={remove}>
In den Papierkorb
</button>
)}
</div>
</form>
{isAdmin && trace && !logOpen && (
<div className="journal-debug">
<h2>Verlauf</h2>
<CallTrace trace={trace} />
</div>
)}
{versions.length > 0 && (
<div className="stack">
<h2>Versionen</h2>
<ul className="stack">
{versions.map((item) => {
const isCurrent = item.id === (currentVersionId || versions.at(-1)?.id)
return (
<li key={item.id} className="row-item">
<span>
{entryTitle(item.title) || originLabel(item.origin)}
{' · '}
{originLabel(item.origin)}
{' · '}
{formatWhen(item.created)}
{isCurrent ? ' · Aktuell' : ''}
</span>
{isCurrent ? (
dirty ? (
<button type="button" className="ghost" onClick={revertChanges} disabled={busy}>
Alle Änderungen rückgängig machen
</button>
) : null
) : (
<button type="button" className="ghost" onClick={() => restore(item.id)} disabled={busy}>
Wiederherstellen
</button>
)}
</li>
)
})}
</ul>
</div>
)}
<RunLogPopup
open={logOpen}
status="ok"
title="Journalentwurf"
log={runLog.length ? runLog : (trace?.log || [])}
trace={trace}
showTrace={isAdmin}
onClose={() => setLogOpen(false)}
/>
</section>
)
}