373 lines
13 KiB
React
373 lines
13 KiB
React
|
|
import { useEffect, useRef, useState } from 'react'
|
||
|
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||
|
|
import { api, apiBlob, apiUpload } from '../api.js'
|
||
|
|
import JournalDocumentEditor from '../components/JournalDocumentEditor.jsx'
|
||
|
|
import { useAuth } from '../context/AuthContext.jsx'
|
||
|
|
import { useUnsavedChanges } from '../context/UnsavedChanges.jsx'
|
||
|
|
import { entryTitle, formatWhen, originLabel } from '../journal/document.js'
|
||
|
|
|
||
|
|
export default function JournalEditorPage() {
|
||
|
|
const { spaceId, dayId } = useParams()
|
||
|
|
const [params] = useSearchParams()
|
||
|
|
const entryQuery = params.get('entry') || ''
|
||
|
|
const wantDraft = params.get('draft') === '1'
|
||
|
|
const { session } = 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 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 || '')
|
||
|
|
markClean(entry.title || '', entry.body || '')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
setEntryId('')
|
||
|
|
entryIdRef.current = ''
|
||
|
|
setTitle(entryTitle(draft?.title || ''))
|
||
|
|
setBody(draft?.body || '')
|
||
|
|
setVersions([])
|
||
|
|
setMedia([])
|
||
|
|
setSavedEntryId('')
|
||
|
|
setCurrentVersionId('')
|
||
|
|
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(() => () => 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 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.')) return
|
||
|
|
setError('')
|
||
|
|
setNotice('')
|
||
|
|
try {
|
||
|
|
await api(`/api/journal/entries/${entryId}`, { token: session.token, method: 'DELETE' })
|
||
|
|
showLoaded(null)
|
||
|
|
setNotice('Im Papierkorb.')
|
||
|
|
} 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}` : ''}
|
||
|
|
</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>}
|
||
|
|
<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>
|
||
|
|
{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>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
)
|
||
|
|
}
|