chore: bump version to 0.8.96 and enhance legal document features
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 59s

- Updated app version to 0.8.96 with a new build date of 2026-05-12.
- Improved legal documents functionality with a live preview feature alongside the editor.
- Added modal for full document preview and updated CSS styles for better layout.
- Enhanced the AdminLegalDocumentsPage to support rendering previews of legal documents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-12 11:22:01 +02:00
parent 81b9e8f601
commit 98edb282ed
4 changed files with 302 additions and 18 deletions

View File

@ -1,11 +1,11 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.95"
APP_VERSION = "0.8.96"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260511053"
MODULE_VERSIONS = {
"legal_documents": "1.3.0", # P-01: Ausgabe §-Nummerierung pro Abschnitt; Markdown im Fließtext + PDF; gem. legalPdfExport
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
@ -34,6 +34,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.96",
"date": "2026-05-12",
"changes": [
"P-01 Admin Rechtstexte: Live-Vorschau je Abschnitt (Markdown) neben dem Editor; modale „Vollständige Vorschau“ aus dem Formular; Augen-Symbol in der Dokumentenliste für die gerenderte Ansicht (API-Laden).",
],
},
{
"version": "0.8.95",
"date": "2026-05-12",

View File

@ -368,6 +368,82 @@ ul > li.card + li.card,
margin: 0.85em 0;
}
/* Admin: Rechtstext Editor — Live-Vorschau neben Textarea */
.legal-section-split {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 4px;
}
@media (min-width: 720px) {
.legal-section-split {
flex-direction: row;
align-items: flex-start;
}
.legal-section-split__editor {
flex: 1;
min-width: 0;
}
.legal-section-split__preview {
flex: 1;
min-width: 0;
}
}
.legal-section-preview-box {
padding: 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
min-height: 100px;
}
.legal-section-preview-box h4 {
font-size: 0.95rem;
font-weight: 700;
margin: 0 0 0.45rem;
color: var(--text1);
}
/* Modales Vorschau-Overlay (Rechtstexte Admin) */
.legal-preview-modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-start;
justify-content: center;
padding: max(16px, env(safe-area-inset-top, 0px)) 16px 24px;
overflow-y: auto;
}
.legal-preview-modal {
width: 100%;
max-width: 760px;
margin-top: 4vh;
margin-bottom: 4vh;
background: var(--surface);
border-radius: 12px;
border: 1px solid var(--border);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
max-height: min(92vh, 960px);
display: flex;
flex-direction: column;
}
.legal-preview-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.legal-preview-modal__body {
padding: 16px 18px 20px;
overflow-y: auto;
min-height: 0;
flex: 1;
}
.form-input {
width: 100%;
min-width: 0;

View File

@ -0,0 +1,111 @@
import { useEffect } from 'react'
import LegalDocumentBody from './LegalDocumentBody'
import { legalSectionNumber } from '../utils/legalPdfExport'
/** Inhalt wie auf der öffentlichen Rechtstextseite (inkl. §-Nummerierung). */
export function LegalDocumentPublicPreviewContent({
title,
sections,
showDraftNotice = true,
metaLine,
}) {
const safeTitle = (title || '').trim() || 'Ohne Titel'
return (
<div className="legal-admin-preview-inner">
{showDraftNotice && (
<div
className="card"
style={{
marginBottom: '1.25rem',
borderLeft: '4px solid var(--warn)',
background: 'var(--surface)',
}}
>
<strong style={{ color: 'var(--text1)' }}>Vorschau</strong>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.88rem', color: 'var(--text2)' }}>
So erscheint der Text für Besucher nach Veröffentlichung (Markdown wird gerendert, §-Nummern wie online).
</p>
</div>
)}
{metaLine && (
<p style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '0 0 1rem' }}>{metaLine}</p>
)}
<h1 style={{ margin: '0 0 1.5rem', color: 'var(--text1)', fontSize: '1.5rem' }}>{safeTitle}</h1>
{(sections || []).map((section, i) => (
<div key={i} style={{ marginBottom: '1.75rem' }}>
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.4rem', color: 'var(--text1)' }}>
{section.heading?.trim()
? `${legalSectionNumber(i)} ${section.heading}`
: legalSectionNumber(i)}
</h2>
<LegalDocumentBody content={section.content} />
</div>
))}
{sections?.length === 0 && (
<p style={{ color: 'var(--text3)', fontSize: '0.9rem' }}>Noch keine Abschnitte.</p>
)}
</div>
)
}
/**
* Modal: gerenderte Rechtstext-Vorschau (Editor oder gespeicherte Version).
*/
export function LegalPreviewModal({
open,
onClose,
title,
sections,
metaLine,
loading,
showDraftNotice = true,
}) {
useEffect(() => {
if (!open) return
const onKey = (e) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
return (
<div
className="legal-preview-modal-backdrop"
role="presentation"
onClick={onClose}
>
<div
className="legal-preview-modal"
role="dialog"
aria-modal="true"
aria-labelledby="legal-preview-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="legal-preview-modal__header">
<h3 id="legal-preview-modal-title" style={{ margin: 0, fontSize: '1.05rem' }}>
Öffentliche Darstellung
</h3>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
<div className="legal-preview-modal__body">
{loading ? (
<div className="spinner" style={{ margin: '2rem auto' }} />
) : (
<LegalDocumentPublicPreviewContent
title={title}
sections={sections}
metaLine={metaLine}
showDraftNotice={showDraftNotice}
/>
)}
</div>
</div>
</div>
)
}

View File

@ -1,7 +1,9 @@
import { useState, useEffect, useCallback } from 'react'
import { FileText, Plus, Edit2, Archive, CheckCircle, Clock, Copy, Download, ChevronUp, ChevronDown } from 'lucide-react'
import { FileText, Plus, Edit2, Archive, CheckCircle, Clock, Copy, Download, ChevronUp, ChevronDown, Eye } from 'lucide-react'
import api from '../utils/api'
import { generateLegalPdf } from '../utils/legalPdfExport'
import { generateLegalPdf, legalSectionNumber } from '../utils/legalPdfExport'
import LegalDocumentBody from '../components/LegalDocumentBody'
import { LegalPreviewModal } from '../components/LegalDocumentPreview'
const PDF_STATUS_META = { published: 'Gültig seit', draft: 'Entwurf — Stand', archived: 'Archiviert — Stand' }
@ -109,18 +111,35 @@ function SectionEditor({ sections, onChange }) {
placeholder="Abschnittsüberschrift"
/>
</div>
<div className="form-row">
<label className="form-label">
Inhalt
<span className="form-sub">
Einfache Markdown-Formatierung: **fett**, *kursiv*, Listen (- oder 1.), [Link](https://), Zeilenumbruch mit Leerzeile.
<div className="legal-section-split">
<div className="legal-section-split__editor">
<label className="form-label">
Inhalt
<span className="form-sub">
Einfache Markdown-Formatierung: **fett**, *kursiv*, Listen (- oder 1.), [Link](https://), Zeilenumbruch mit Leerzeile.
</span>
</label>
<textarea
className="form-input" rows={6} value={sec.content}
onChange={e => update(i, 'content', e.target.value)}
placeholder="Textinhalt des Abschnitts"
/>
</div>
<div className="legal-section-split__preview">
<span className="form-label" style={{ fontSize: '0.8rem', display: 'block', marginBottom: '6px' }}>
Live-Vorschau
</span>
</label>
<textarea
className="form-input" rows={4} value={sec.content}
onChange={e => update(i, 'content', e.target.value)}
placeholder="Textinhalt des Abschnitts"
/>
<div className="legal-section-preview-box" style={{ marginBottom: 0 }}>
<h4>
{sec.heading?.trim()
? `${legalSectionNumber(i)} ${sec.heading}`
: legalSectionNumber(i)}
</h4>
{sec.content
? <LegalDocumentBody content={sec.content} />
: <span style={{ color: 'var(--text3)', fontSize: '0.85rem' }}>(noch kein Text)</span>}
</div>
</div>
</div>
</div>
<InsertButton afterIndex={i} />
@ -135,7 +154,7 @@ function SectionEditor({ sections, onChange }) {
)
}
function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, onViewAudit }) {
function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, onViewAudit, onRenderedPreview }) {
const [downloading, setDownloading] = useState(false)
const handleDownload = async () => {
@ -183,6 +202,10 @@ function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, on
<Archive size={13} />
</button>
)}
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onRenderedPreview(doc)} title="Gerenderte Vorschau">
<Eye size={13} />
</button>
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onCopy(doc)} title="Als neuen Entwurf kopieren">
<Copy size={13} />
@ -200,7 +223,7 @@ function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, on
)
}
function EditForm({ docType, editDoc, onSaved, onCancel }) {
function EditForm({ docType, editDoc, onSaved, onCancel, onShowRenderedPreview }) {
const [title, setTitle] = useState(editDoc ? editDoc.title : docType.defaultTitle)
const [sections, setSections] = useState([])
const [changeNote, setChangeNote] = useState('')
@ -266,10 +289,23 @@ function EditForm({ docType, editDoc, onSaved, onCancel }) {
onChange={e => setChangeNote(e.target.value)}
placeholder="z. B. Erste Version nach juristischer Prüfung" />
</div>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !title.trim()}>
{saving ? 'Speichern…' : 'Entwurf speichern'}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() =>
onShowRenderedPreview?.({
title,
sections,
metaLine: 'Aktueller Editorstand (nicht automatisch gespeichert)',
})}
style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}
>
<Eye size={15} /> Vollständige Vorschau
</button>
<button className="btn btn-secondary" onClick={onCancel}>Abbrechen</button>
</div>
</div>
@ -329,6 +365,13 @@ export default function AdminLegalDocumentsPage() {
const [editDoc, setEditDoc] = useState(null)
const [auditDocId, setAuditDocId] = useState(null)
const [confirmPublish, setConfirmPublish] = useState(null)
const [legalPreview, setLegalPreview] = useState({
open: false,
loading: false,
title: '',
sections: [],
metaLine: '',
})
const activeDocType = DOC_TYPES.find(d => d.key === activeType)
@ -376,6 +419,42 @@ export default function AdminLegalDocumentsPage() {
catch (e) { alert('Fehler: ' + e.message) }
}
const closeLegalPreview = () =>
setLegalPreview({ open: false, loading: false, title: '', sections: [], metaLine: '' })
const openLegalPreviewFromEditor = (payload) => {
setLegalPreview({
open: true,
loading: false,
title: payload.title,
sections: payload.sections,
metaLine: payload.metaLine || '',
})
}
const openLegalPreviewFromList = async (doc) => {
setLegalPreview({
open: true,
loading: true,
title: '',
sections: [],
metaLine: 'Laden…',
})
try {
const full = await api.getLegalDocument(doc.id)
setLegalPreview({
open: true,
loading: false,
title: full.title,
sections: full.content_sections || [],
metaLine: `Version ${full.version} · ${STATUS_LABELS[full.status]?.label || full.status}`,
})
} catch (e) {
closeLegalPreview()
alert('Fehler: ' + e.message)
}
}
const handleDownload = async (doc) => {
const full = await api.getLegalDocument(doc.id)
const dateStr = full.published_at
@ -473,6 +552,7 @@ export default function AdminLegalDocumentsPage() {
onCopy={handleCopy}
onDownload={handleDownload}
onViewAudit={handleViewAudit}
onRenderedPreview={openLegalPreviewFromList}
/>
))
)}
@ -483,9 +563,19 @@ export default function AdminLegalDocumentsPage() {
editDoc={editDoc}
onSaved={handleSaved}
onCancel={() => { setShowForm(false); setEditDoc(null) }}
onShowRenderedPreview={openLegalPreviewFromEditor}
/>
)}
<LegalPreviewModal
open={legalPreview.open}
onClose={closeLegalPreview}
title={legalPreview.title}
sections={legalPreview.sections}
metaLine={legalPreview.metaLine}
loading={legalPreview.loading}
/>
{auditDocId && (
<AuditLog docId={auditDocId} onClose={() => setAuditDocId(null)} />
)}