Module und Kombinationsübnungen in Version 0.8 #31

Merged
Lars merged 27 commits from develop into main 2026-05-13 16:16:49 +02:00
4 changed files with 302 additions and 18 deletions
Showing only changes of commit 98edb282ed - Show all commits

View File

@ -1,11 +1,11 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.95" APP_VERSION = "0.8.96"
BUILD_DATE = "2026-05-12" BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260511053" DB_SCHEMA_VERSION = "20260511053"
MODULE_VERSIONS = { 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 "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() "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 "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 = [ 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", "version": "0.8.95",
"date": "2026-05-12", "date": "2026-05-12",

View File

@ -368,6 +368,82 @@ ul > li.card + li.card,
margin: 0.85em 0; 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 { .form-input {
width: 100%; width: 100%;
min-width: 0; 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 { 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 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' } const PDF_STATUS_META = { published: 'Gültig seit', draft: 'Entwurf — Stand', archived: 'Archiviert — Stand' }
@ -109,7 +111,8 @@ function SectionEditor({ sections, onChange }) {
placeholder="Abschnittsüberschrift" placeholder="Abschnittsüberschrift"
/> />
</div> </div>
<div className="form-row"> <div className="legal-section-split">
<div className="legal-section-split__editor">
<label className="form-label"> <label className="form-label">
Inhalt Inhalt
<span className="form-sub"> <span className="form-sub">
@ -117,11 +120,27 @@ function SectionEditor({ sections, onChange }) {
</span> </span>
</label> </label>
<textarea <textarea
className="form-input" rows={4} value={sec.content} className="form-input" rows={6} value={sec.content}
onChange={e => update(i, 'content', e.target.value)} onChange={e => update(i, 'content', e.target.value)}
placeholder="Textinhalt des Abschnitts" placeholder="Textinhalt des Abschnitts"
/> />
</div> </div>
<div className="legal-section-split__preview">
<span className="form-label" style={{ fontSize: '0.8rem', display: 'block', marginBottom: '6px' }}>
Live-Vorschau
</span>
<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> </div>
<InsertButton afterIndex={i} /> <InsertButton afterIndex={i} />
</div> </div>
@ -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 [downloading, setDownloading] = useState(false)
const handleDownload = async () => { const handleDownload = async () => {
@ -183,6 +202,10 @@ function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, on
<Archive size={13} /> <Archive size={13} />
</button> </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' }} <button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onCopy(doc)} title="Als neuen Entwurf kopieren"> onClick={() => onCopy(doc)} title="Als neuen Entwurf kopieren">
<Copy size={13} /> <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 [title, setTitle] = useState(editDoc ? editDoc.title : docType.defaultTitle)
const [sections, setSections] = useState([]) const [sections, setSections] = useState([])
const [changeNote, setChangeNote] = useState('') const [changeNote, setChangeNote] = useState('')
@ -266,10 +289,23 @@ function EditForm({ docType, editDoc, onSaved, onCancel }) {
onChange={e => setChangeNote(e.target.value)} onChange={e => setChangeNote(e.target.value)}
placeholder="z. B. Erste Version nach juristischer Prüfung" /> placeholder="z. B. Erste Version nach juristischer Prüfung" />
</div> </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()}> <button className="btn btn-primary" onClick={handleSave} disabled={saving || !title.trim()}>
{saving ? 'Speichern…' : 'Entwurf speichern'} {saving ? 'Speichern…' : 'Entwurf speichern'}
</button> </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> <button className="btn btn-secondary" onClick={onCancel}>Abbrechen</button>
</div> </div>
</div> </div>
@ -329,6 +365,13 @@ export default function AdminLegalDocumentsPage() {
const [editDoc, setEditDoc] = useState(null) const [editDoc, setEditDoc] = useState(null)
const [auditDocId, setAuditDocId] = useState(null) const [auditDocId, setAuditDocId] = useState(null)
const [confirmPublish, setConfirmPublish] = 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) const activeDocType = DOC_TYPES.find(d => d.key === activeType)
@ -376,6 +419,42 @@ export default function AdminLegalDocumentsPage() {
catch (e) { alert('Fehler: ' + e.message) } 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 handleDownload = async (doc) => {
const full = await api.getLegalDocument(doc.id) const full = await api.getLegalDocument(doc.id)
const dateStr = full.published_at const dateStr = full.published_at
@ -473,6 +552,7 @@ export default function AdminLegalDocumentsPage() {
onCopy={handleCopy} onCopy={handleCopy}
onDownload={handleDownload} onDownload={handleDownload}
onViewAudit={handleViewAudit} onViewAudit={handleViewAudit}
onRenderedPreview={openLegalPreviewFromList}
/> />
)) ))
)} )}
@ -483,9 +563,19 @@ export default function AdminLegalDocumentsPage() {
editDoc={editDoc} editDoc={editDoc}
onSaved={handleSaved} onSaved={handleSaved}
onCancel={() => { setShowForm(false); setEditDoc(null) }} 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 && ( {auditDocId && (
<AuditLog docId={auditDocId} onClose={() => setAuditDocId(null)} /> <AuditLog docId={auditDocId} onClose={() => setAuditDocId(null)} />
)} )}