495 lines
17 KiB
JavaScript
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>
|
|
)
|
|
}
|