Kant CMS: Grundgerüst, visueller Editor, Mediathek

Symfony 7.4 / Twig / Doctrine DBAL auf herkömmlichem PHP-Webhosting.
Versionierte Blockdokumente (v1-v3) mit unveränderlichen Revisionen,
getrennten Entwürfen/Veröffentlichungen und optimistischer
Konflikterkennung. Visueller Editor mit Strukturbaum, Live-Vorschau,
responsiven Gerätewerten, Abschnittsvorlagen, Bildmediathek,
Galerien und Effekten. Öffentliche Ausgabe ohne Editor-JavaScript.

Geprüft: 41 PHP-Tests / 155 Assertions, 10 JavaScript-Modelltests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 10:19:05 +02:00
commit 82b07820bc
87 files changed
+14463

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}Verwaltung{% endblock %} · Kant CMS</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/assets/admin.css">
{% block scripts %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
<a class="skip-link" href="#admin-main">Zum Inhalt springen</a>
{% block body %}{% endblock %}
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
{% extends 'admin/base.html.twig' %}
{% block title %}{{ page.document.title }} bearbeiten{% endblock %}
{% block body_class %}editor-body{% endblock %}
{% block scripts %}<script type="module" src="/assets/editor.js"></script>{% endblock %}
{% block body %}
<div id="editor" data-page-id="{{ page.id }}" data-can-publish="{{ is_granted('ROLE_PUBLISHER') ? 'true' : 'false' }}">
<header class="editor-header">
<a class="back-link" href="{{ path('admin_pages') }}" aria-label="Zur Seitenübersicht">← <span>Seiten</span></a>
<div class="editor-page-name"><h1>{{ page.document.title }}</h1><span>/{{ page.slug == 'home' ? '' : page.slug }}</span></div>
<div class="history-actions"><button id="undo" class="icon-button" title="Rückgängig (Strg+Z)" aria-label="Rückgängig" disabled>↶</button><button id="redo" class="icon-button" title="Wiederholen (Strg+Umschalt+Z)" aria-label="Wiederholen" disabled>↷</button></div>
<div class="save-actions"><span id="save-status" role="status" aria-live="polite">Wird geladen …</span><button id="save" class="secondary" disabled>Entwurf speichern</button>{% if is_granted('ROLE_PUBLISHER') %}<button id="publish" class="primary" disabled>Veröffentlichen</button>{% endif %}</div>
</header>
<div id="message" class="editor-message" role="alert" hidden></div>
<div id="recovery" class="recovery-bar" hidden><button id="download" class="secondary">Änderungen als JSON sichern</button><button id="reload" class="secondary">Serverstand laden</button><a href="{{ path('admin_login') }}" target="_blank" rel="noopener">Erneut anmelden ↗</a></div>
<main id="admin-main" class="editor-layout" tabindex="-1">
<aside class="left-panel" aria-label="Elemente und Seitenstruktur">
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><h3 class="preset-heading">Element hinzufügen</h3>
<p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element.</p>
<div id="library" class="library">
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦'}[type] }}</span>{{ label }}</button>{% endfor %}
</div>
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
<div id="presets" class="preset-library"><button data-preset="intro" disabled><span aria-hidden="true">▤</span> Intro mit Button</button><button data-preset="columns" disabled><span aria-hidden="true">▥</span> Zwei Leistungen</button><button data-preset="cta" disabled><span aria-hidden="true">↗</span> Kontaktaufruf</button></div>
</div></section>
<section class="panel-section structure-section"><div class="panel-title"><h2>Struktur</h2><button id="select-page" class="text-button">Seite</button></div><label class="search-label" for="tree-search">Element suchen</label><input id="tree-search" type="search" placeholder="Text oder Elementtyp …"><div class="tree-tools"><button id="expand-all" class="text-button">Alle öffnen</button><button id="collapse-all" class="text-button">Einklappen</button></div><p class="panel-hint">Ziehen oder mit den Schaltflächen verschieben.</p><nav id="structure" aria-label="Blöcke auswählen"></nav>
<div class="block-actions"><button id="move-up" title="Nach oben verschieben" aria-label="Nach oben verschieben">↑</button><button id="move-down" title="Nach unten verschieben" aria-label="Nach unten verschieben">↓</button><button id="nest" title="In vorherigen Container verschachteln" aria-label="In vorherigen Container verschachteln">⇥</button><button id="outdent" title="Eine Ebene herausheben" aria-label="Eine Ebene herausheben">⇤</button><button id="duplicate">Duplizieren</button><button id="delete" class="danger-text">Löschen</button></div>
</section>
</aside>
<section class="canvas-panel" aria-label="Live-Vorschau">
<div class="canvas-toolbar"><span class="canvas-label">LIVE-VORSCHAU</span><div class="viewport-controls" role="group" aria-label="Vorschaugröße"><button data-viewport="desktop" aria-pressed="true">Desktop</button><button data-viewport="tablet" aria-pressed="false">Tablet</button><button data-viewport="mobile" aria-pressed="false">Mobil</button></div><a href="{{ page.slug == 'home' ? '/' : '/' ~ page.slug }}" target="_blank" rel="noopener" aria-label="Öffentliche Seite öffnen">↗</a></div>
<p id="preview-status" class="preview-status" role="status">Vorschau wird geladen …</p>
<nav id="breadcrumbs" class="breadcrumbs" aria-label="Ausgewähltes Element"></nav>
<div class="canvas-scroll" tabindex="0" role="region" aria-label="Vorschau scrollen"><div id="preview-stage"><iframe id="preview" title="Vorschau der bearbeiteten Seite" sandbox="allow-same-origin" class="viewport-desktop"></iframe></div></div>
</section>
<aside class="right-panel" aria-label="Eigenschaften"><div class="panel-section"><p class="eyebrow">FEINSCHLIFF</p><h2 id="properties-title">Seiteneinstellungen</h2><div class="property-tabs" role="group" aria-label="Eigenschaftenbereich"><button data-panel="content" aria-pressed="true">Inhalt</button><button data-panel="design" aria-pressed="false">Design</button></div><div id="properties"><p class="muted">Inhalte werden geladen …</p></div></div><div class="panel-note">Die Vorschau zeigt deinen Entwurf. Besucher sehen weiterhin den veröffentlichten Stand.</div></aside>
</main>
<noscript><p class="notice error">Zum Bearbeiten muss JavaScript aktiviert sein. Die öffentliche Website funktioniert ohne JavaScript.</p></noscript>
<dialog id="publish-dialog" aria-labelledby="publish-title"><h2 id="publish-title">Seite veröffentlichen?</h2><p>Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.</p><div class="dialog-actions"><button id="cancel-publish" class="secondary">Abbrechen</button><button id="confirm-publish" class="primary">Jetzt veröffentlichen</button></div></dialog>
<dialog id="reload-dialog" aria-labelledby="reload-title"><h2 id="reload-title">Serverstand laden?</h2><p>Lokale Änderungen und dein Undo-Verlauf werden ersetzt. Sichere deine Änderungen vorher als JSON.</p><div class="dialog-actions"><button id="cancel-reload" class="secondary">Abbrechen</button><button id="confirm-reload" class="primary">Serverstand übernehmen</button></div></dialog>
</div>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Anmelden{% endblock %}
{% block body_class %}login-body{% endblock %}
{% block body %}
<main id="admin-main" class="login-card" tabindex="-1">
<a class="brand" href="/" aria-label="Kant CMS – öffentliche Website"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>cms</span></a>
<p class="eyebrow">DEIN RAUM ZUM GESTALTEN</p>
<h1>Willkommen zurück.</h1>
<p class="muted">Melde dich an, um deine Seiten zu bearbeiten.</p>
{% if error %}<p class="notice error" role="alert">Anmeldung fehlgeschlagen. Prüfe deine Zugangsdaten und versuche es erneut. Nach mehreren Versuchen bitte eine Minute warten.</p>{% endif %}
<form method="post" action="{{ path('admin_login') }}">
<label for="username">Benutzername</label>
<input id="username" name="_username" value="{{ username }}" autocomplete="username" required maxlength="100" autofocus>
<label for="password">Passwort</label>
<input id="password" name="_password" type="password" autocomplete="current-password" required>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<button class="primary full-width" type="submit">Anmelden <span aria-hidden="true">→</span></button>
</form>
<a class="quiet-link" href="/">Zur öffentlichen Website</a>
</main>
{% endblock %}
+4
View File
@@ -0,0 +1,4 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Medienverwaltung{% endblock %}
{% block scripts %}<script type="module" src="/assets/media.js"></script>{% endblock %}
{% block body %}<header class="admin-header"><a href="/admin">← Seitenübersicht</a></header><main id="admin-main" class="pages-main"><p class="eyebrow">DEINE BILDER</p><h1>Medienverwaltung</h1><p class="muted">Bilder hochladen, Angaben pflegen und in deinen Seiten wiederverwenden.</p><button id="open-media-library" class="primary">Mediathek öffnen</button></main>{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Deine Seiten{% endblock %}
{% block body %}
<header class="admin-header">
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>cms</span></a>
<div class="header-actions"><a href="/">Website ansehen ↗</a><span class="user-name">{{ app.user.userIdentifier }}</span><form action="{{ path('admin_logout') }}" method="post"><input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}"><button class="subtle" type="submit">Abmelden</button></form></div>
</header>
<main id="admin-main" class="pages-main" tabindex="-1">
<p class="eyebrow">DEINE WEBSITE</p><h1>Gute Inhalte beginnen hier.</h1>
<p class="muted">Wähle eine Seite. Gestalte deinen Entwurf. Veröffentliche, wenn alles passt.</p>
<a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a>
<div class="section-heading"><h2>Seiten <span class="count">{{ pages|length }}</span></h2><span class="muted">Entwürfe bleiben bis zur Veröffentlichung privat.</span></div>
<div class="page-list">
{% for page in pages %}
<article class="page-card">
<div class="page-icon" aria-hidden="true">▤</div>
<div class="page-info"><h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3><p>/{{ page.slug == 'home' ? '' : page.slug }} <span>· {{ page.locale|upper }}</span></p></div>
<span class="badge {{ page.published_revision_id == page.draft_revision_id ? 'published' : '' }}">{{ page.published_revision_id == page.draft_revision_id ? 'Veröffentlicht' : 'Entwurf geändert' }}</span>
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
</article>
{% else %}
<div class="empty-state"><h3>Noch keine Seiten vorhanden.</h3><p>Die Beispielseiten können über das lokale Setup angelegt werden.</p></div>
{% endfor %}
</div>
<p class="footnote">Kant CMS · Visueller Editor, erster Prototyp</p>
</main>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% set type = block.type %}
{% set props = block.props|default({}) %}
{% set layout = block.layout|default({}) %}
{% set designClasses = '' %}
{% for device, settings in layout.design|default({}) %}
{% for key, value in settings %}{% set designClasses = designClasses ~ ' d-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}
{% endfor %}
{% for key, value in layout.effects|default({}) %}{% set designClasses = designClasses ~ ' fx-' ~ key ~ '-' ~ value %}{% endfor %}
{% if type in ['section', 'row', 'column'] %}
{% set tag = type == 'section' ? 'section' : 'div' %}
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}">
{% if type == 'section' %}<div class="shell section-inner">{% endif %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child} only %}{% endfor %}
{% if type == 'section' %}</div>{% endif %}
</{{ tag }}>
{% elseif type == 'heading' %}
<h{{ props.level }} id="{{ block.id }}" class="{{ designClasses|trim }}">{{ props.text }}</h{{ props.level }}>
{% elseif type == 'text' %}
<p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p>
{% elseif type == 'button' %}
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}">{{ props.text }} <span aria-hidden="true">↗</span></a>
{% elseif type == 'image' %}
<figure id="{{ block.id }}" class="block-image{{ designClasses }}"><img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="lazy" decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px"{% endif %}>{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
{% elseif type == 'gallery' %}
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie">
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child} only %}{% endfor %}
</div>
{% else %}
<p>Dieser Inhaltsblock ist derzeit nicht verfügbar.</p>
{% endif %}
+3
View File
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>Seite nicht gefunden · Kant CMS</title><link rel="stylesheet" href="/assets/site.css"><link rel="icon" href="/favicon.svg" type="image/svg+xml"></head>
<body><main class="shell error-page"><p>404 · Kant CMS</p><h1>Hier ist noch keine Seite.</h1><p>Die Adresse existiert nicht oder die Seite ist noch nicht veröffentlicht.</p><a class="button" href="/">Zur Startseite</a></main></body></html>
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="{{ page.locale }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page.document.title }}</title>
<meta name="description" content="{{ page.document.description }}">
<link rel="canonical" href="{{ canonical }}">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/assets/site.css">
{% if page.document.schemaVersion >= 2 %}<link rel="stylesheet" href="/assets/design.css">{% endif %}
{% if page.document.schemaVersion >= 3 %}<link rel="stylesheet" href="/assets/enhancements.css">{% endif %}
</head>
<body>
<a class="skip-link" href="#main">Zum Inhalt springen</a>
<header class="site-header shell">
<a class="brand" href="/" aria-label="Kant CMS – Startseite"><span class="brand-icon" aria-hidden="true">k.</span> kant<span class="brand-light">cms</span></a>
<nav aria-label="Hauptnavigation">
<a href="/"{% if page.slug == 'home' %} aria-current="page"{% endif %}>Überblick</a>
<a href="/grundlagen"{% if page.slug == 'grundlagen' %} aria-current="page"{% endif %}>Grundlagen</a>
<a class="nav-link" href="/projektstand"{% if page.slug == 'projektstand' %} aria-current="page"{% endif %}>Projektstand <span aria-hidden="true">↗</span></a>
</nav>
</header>
<main id="main" tabindex="-1">
{% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block} only %}{% endfor %}
</main>
<footer class="site-footer shell"><a class="brand" href="/">kant<span class="brand-light">cms</span></a><p>Eine Grundlage. Viele Möglichkeiten.</p><span>Lokaler Entwicklungsprototyp · 2026</span></footer>
</body>
</html>