2026-08-25 13:57:23 +02:00
import { useEffect , useRef , useState } from 'react'
2026-08-26 10:42:20 +02:00
import { Link , useLocation , useNavigate , useParams , useSearchParams } from 'react-router-dom'
2026-08-29 11:04:34 +02:00
import { api , apiBlob , apiDownload , apiUpload } from '../api.js'
2026-08-26 10:42:20 +02:00
import CallTrace from '../components/CallTrace.jsx'
2026-08-25 13:57:23 +02:00
import JournalDocumentEditor from '../components/JournalDocumentEditor.jsx'
2026-08-27 10:03:05 +02:00
import RunLogPopup from '../components/RunLogPopup.jsx'
2026-08-25 13:57:23 +02:00
import { useAuth } from '../context/AuthContext.jsx'
import { useUnsavedChanges } from '../context/UnsavedChanges.jsx'
import { entryTitle , formatWhen , originLabel } from '../journal/document.js'
2026-08-29 11:04:34 +02:00
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 )
}
2026-08-25 13:57:23 +02:00
export default function JournalEditorPage ( ) {
const { spaceId , dayId } = useParams ( )
const [ params ] = useSearchParams ( )
2026-08-26 10:42:20 +02:00
const location = useLocation ( )
2026-08-25 13:57:23 +02:00
const entryQuery = params . get ( 'entry' ) || ''
const wantDraft = params . get ( 'draft' ) === '1'
2026-08-26 10:42:20 +02:00
const { session , isAdmin } = useAuth ( )
2026-08-25 13:57:23 +02:00
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 : '' } )
2026-08-29 11:04:34 +02:00
const [ logOpen , setLogOpen ] = useState ( false )
2026-08-28 08:40:28 +02:00
const [ generationSummary , setGenerationSummary ] = useState ( '' )
2026-08-29 11:04:34 +02:00
const [ trace , setTrace ] = useState ( ( ) => navTrace ( location . state ) )
const [ runLog , setRunLog ] = useState ( ( ) => navLog ( location . state ) )
const [ debugOn , setDebugOn ] = useState ( false )
2026-08-25 13:57:23 +02:00
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 || '' )
2026-08-28 08:40:28 +02:00
setGenerationSummary ( '' )
2026-08-25 13:57:23 +02:00
markClean ( entry . title || '' , entry . body || '' )
return
}
setEntryId ( '' )
entryIdRef . current = ''
setTitle ( entryTitle ( draft ? . title || '' ) )
setBody ( draft ? . body || '' )
setVersions ( [ ] )
setMedia ( [ ] )
setSavedEntryId ( '' )
setCurrentVersionId ( '' )
2026-08-28 08:40:28 +02:00
setGenerationSummary ( draft ? . generation _summary || '' )
2026-08-25 13:57:23 +02:00
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 ] )
2026-08-29 11:04:34 +02:00
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 ] )
2026-08-25 13:57:23 +02:00
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 ] )
2026-08-29 11:04:34 +02:00
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 )
}
}
2026-08-25 13:57:23 +02:00
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
2026-08-26 10:42:20 +02:00
if ( ! window . confirm ( 'Eintrag in den Papierkorb legen? Das ist noch nicht endgültig. Endgültiges Löschen geschieht später im Papierkorb.' ) ) return
2026-08-25 13:57:23 +02:00
setError ( '' )
setNotice ( '' )
try {
await api ( ` /api/journal/entries/ ${ entryId } ` , { token : session . token , method : 'DELETE' } )
showLoaded ( null )
2026-08-26 10:42:20 +02:00
navigate ( ` /journal/ ${ spaceId } /trash ` )
2026-08-25 13:57:23 +02:00
} 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 } ` : '' }
2026-08-29 11:04:34 +02:00
{ isAdmin && ( trace || runLog . length > 0 ) && (
2026-08-27 10:03:05 +02:00
< >
{ ' · ' }
< button type = "button" className = "ghost" onClick = { ( ) => setLogOpen ( true ) } >
Ablauf anzeigen
< / button >
2026-08-29 11:04:34 +02:00
< button type = "button" className = "ghost" onClick = { downloadDebug } disabled = { busy } >
Debug herunterladen
< / button >
2026-08-27 10:03:05 +02:00
< / >
) }
2026-08-25 13:57:23 +02:00
< / 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 > }
2026-08-28 08:40:28 +02:00
{ generationSummary && < p className = "muted generation-snapshot" > { generationSummary } < / p > }
2026-08-25 13:57:23 +02:00
< 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 >
2026-08-29 11:04:34 +02:00
{ isAdmin && trace && ! logOpen && (
< div className = "journal-debug" >
< h2 > Verlauf < / h2 >
< CallTrace trace = { trace } / >
< / div >
) }
2026-08-25 13:57:23 +02:00
{ 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 >
) }
2026-08-27 10:03:05 +02:00
< RunLogPopup
open = { logOpen }
status = "ok"
title = "Journalentwurf"
2026-08-29 11:04:34 +02:00
log = { runLog . length ? runLog : ( trace ? . log || [ ] ) }
trace = { trace }
2026-08-27 10:03:05 +02:00
showTrace = { isAdmin }
onClose = { ( ) => setLogOpen ( false ) }
/ >
2026-08-25 13:57:23 +02:00
< / section >
)
}