Add videos: library, block and section background

Videos join the media library as their own table (schema v28), with a
video block and a looping background on sections. video.js starts loops
only while visible, offers a pause button and leaves the still image for
reduced motion and data saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWYm8kjAWmZaB2QB2xuBhU
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-11-05 05:23:17 +00:00
1 parent 592e3d5342
commit 76613b9f8e
25 files changed
+674 -20

No files matched your search

+3
View File
@@ -48,6 +48,9 @@ services:
App\Content\DocumentLibrary: App\Content\DocumentLibrary:
arguments: arguments:
$directory: '%env(projectdir:MEDIA_DIRECTORY)%' $directory: '%env(projectdir:MEDIA_DIRECTORY)%'
App\Content\VideoLibrary:
arguments:
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert # Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung. # werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
# Die Stellen, die wissen muessen, wie die Verwaltung heisst. # Die Stellen, die wissen muessen, wie die Verwaltung heisst.
+10
View File
@@ -2,6 +2,16 @@
Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen. Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 125 – Videos: eigene Bibliothek, Baustein und Sektionshintergrund (Dokumentschema v28, 04.11.2026)
**Kontext:** Der Hero von kant-it.de soll ein Video im Hintergrund bekommen. Die Mediathek nahm nur Bilder und PDFs, die Alpaka-Videos von Hüfner und die Drews-Videos blieben deshalb draußen. Ein `<video>` in „Eigenes HTML“ ginge, verlangt aber Dateien von Hand auf dem Server und kann weder Bewegung reduzieren noch ein Anhalten anbieten.
**Entscheidung:** Eigene Tabelle `video` und `VideoLibrary` nach dem Vorbild der Dokumente, Dateien als `<id>.mp4`/`<id>.webm` im Medienordner, bis 64 MB, Typ nach den ersten Bytes (MOV abgewiesen). Ausgeliefert unter `/media/<id>.<endung>` mit Range-Anfragen und `default-src 'none'`. Im Dokument stehen Adressen, nicht Kennungen: Baustein `video` (`src`, `srcWebm`, `poster`, `title`, `mode`: `player`/`loop`) und an der Sektion `props.backgroundVideo`/`backgroundVideoWebm`. Der Validator lässt nur Adressen der eigenen Mediathek zu; fremde Videos bleiben Sache von „Externer Inhalt“ mit Einwilligung.
Schleifen stehen ohne `autoplay` im HTML, mit `preload="none"`. `video.js` startet sie, solange sie zu sehen sind, und zeigt den Knopf zum Anhalten (WCAG 2.2.2). Bei „Bewegung reduzieren“ oder Datensparen bleibt das Standbild, starten lässt es sich von Hand. Das Skript kommt nur auf Seiten mit Schleife; ein Spieler lässt `script-src 'none'` stehen.
**Folgen:** Kein Umrechnen auf dem Server: Wer zu große Dateien hochlädt, bekommt zu große Dateien, deshalb der Hinweis im Dialog. Die PHP-Grenzen `upload_max_filesize` und `post_max_size` des Hostings gelten zusätzlich. Die Überlagerung einer Sektion ist Teil ihres CSS-Hintergrunds und liegt damit unter dem Video. Website-Pakete von vor v28 bleiben importierbar, die Tabelle gilt dort als leer.
## ADR 124 – KantTresor meldet sich in der Zentrale, ohne Update aus der Ferne (27.10.2026) ## ADR 124 – KantTresor meldet sich in der Zentrale, ohne Update aus der Ferne (27.10.2026)
**Kontext:** Umut will in der Zentrale neben den KantSites-Installationen auch die KantTresor-Instanzen sehen (eigener Reiter); ein Update per Knopfdruck braucht KantTresor nicht. **Kontext:** Umut will in der Zentrale neben den KantSites-Installationen auch die KantTresor-Instanzen sehen (eigener Reiter); ein Update per Knopfdruck braucht KantTresor nicht.
+12
View File
@@ -1,5 +1,17 @@
# Projektstatus # Projektstatus
## Videos: Mediathek, Video-Baustein, Sektionshintergrund (04.11.2026)
**Anlass:** Umut: Hero-Video für kant-it.de, „baust du video embedding gleich ein“.
**Erledigt (ADR 125, Schema v28, Migration `Version20261104000100`):** `VideoLibrary` und Tabelle `video`; Upload über die Mediathek und den neuen Dialog `openVideos`; Abschnitt „Videos“ in der Mediathek; Auslieferung `/media/<id>.mp4|webm`; Baustein „Video“ (Spieler oder Schleife, Zweitformat WebM, Standbild, Titel); Hintergrundvideo samt WebM an der Sektion; `video.js` startet Schleifen nur sichtbar, mit Knopf zum Anhalten, ohne Start bei „Bewegung reduzieren“ und Datensparen. Website-Paket nimmt Tabelle und Dateien mit, ältere Pakete bleiben importierbar.
**Geprüft:** PHPUnit 686 Tests unter PHP 8.3, davon nur die sechs `UpdaterTest`-Fälle rot, die in der Testumgebung auch ohne diese Änderung scheitern (kein Git-Verlauf der Arbeitskopie). Neu: `testAVideoFromTheLibraryPlaysAsBlockAndAsSectionBackground`. 41 JavaScript-Tests, Twig-Lint, PHP-Stilprüfung. Playwright (Chromium) in einer isolierten Installation: Upload beider Formate, Auswahl im Editor, Vorschau, Veröffentlichen, WebM läuft, füllt die Sektion, Knopf hält an, Reduzieren startet nichts und lädt nichts, Range-Antwort 206, keine CSP- und Konsolenfehler.
**Nicht geprüft:** Safari und echtes iOS-Gerät, H.264 im Testbrowser (Playwright-Chromium spielt nur WebM), Lighthouse mit Video, Upload auf dem Hosting (PHP-Limits).
**Nächster Schritt:** Release, dann auf kant-it.de hochladen und in der Hero-Sektion als Hintergrundvideo wählen.
## Sicherheitsupdate symfony/mime, Release 1.36.2 (03.11.2026) ## Sicherheitsupdate symfony/mime, Release 1.36.2 (03.11.2026)
**Anlass:** Monatlicher Sicherheitscheck (`reports/sicherheit-2026-11.md`, nicht in Git): `symfony/mime` 7.4.19 betroffen von CVE-2026-45070 (E-Mail-Header-Injection über Parameternamen, behoben ab 7.4.22), in `main` und in v1.36.1. KantSites baut keine MIME-Parameternamen aus Eingaben, Ausnutzbarkeit gering. Umut: „Ja, Update + Release“. **Anlass:** Monatlicher Sicherheitscheck (`reports/sicherheit-2026-11.md`, nicht in Git): `symfony/mime` 7.4.19 betroffen von CVE-2026-45070 (E-Mail-Header-Injection über Parameternamen, behoben ab 7.4.22), in `main` und in v1.36.1. KantSites baut keine MIME-Parameternamen aus Eingaben, Ausnutzbarkeit gering. Umut: „Ja, Update + Release“.
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261104000100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Videos in der Mediathek: fuer den Video-Baustein und den Hintergrund einer Sektion.';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
// Eine eigene Tabelle wie bei den Dokumenten: jede Stelle, die ein Bild sucht, erwartet
// Varianten mit Breite und Hoehe.
$video = $schema->createTable('video');
$video->addColumn('id', 'string', ['length' => 32]);
$video->addColumn('title', 'string', ['length' => 160]);
$video->addColumn('mime', 'string', ['length' => 16]);
$video->addColumn('bytes', 'integer');
$video->addColumn('archived', 'boolean', ['default' => false]);
$video->addColumn('version', 'integer', ['default' => 1]);
$video->addColumn('created_at', 'datetime_immutable');
$video->setPrimaryKey(['id']);
}
public function down(Schema $schema): void
{
$schema->dropTable('video');
}
}
+8 -5
View File
@@ -1,5 +1,5 @@
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML', language: 'Sprachwahl' }; export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', video: 'Video', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML', language: 'Sprachwahl' };
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>', language: '文' }; export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', video: '▶', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>', language: '文' };
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the // A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
// document, stay selectable and movable, but the editor never edits or fills them. // document, stay selectable and movable, but the editor never edits or fills them.
/** /**
@@ -9,7 +9,7 @@ export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H',
*/ */
export const CATEGORIES = [ export const CATEGORIES = [
{ name: 'Aufbau', hint: 'Sektion → Zeile → Spalte. Der Inhalt kommt in die Spalte.', types: ['section', 'row', 'column'] }, { name: 'Aufbau', hint: 'Sektion → Zeile → Spalte. Der Inhalt kommt in die Spalte.', types: ['section', 'row', 'column'] },
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] }, { name: 'Inhalt', types: ['heading', 'text', 'image', 'video', 'button', 'icon', 'divider', 'spacer'] },
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] }, { name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] },
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] }, { name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'language', 'form', 'legal', 'partialref', 'embed', 'html'] }, { name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'language', 'form', 'legal', 'partialref', 'embed', 'html'] },
@@ -132,6 +132,8 @@ export function createBlock(type) {
if (type === 'language') block.props = { display: 'code' }; if (type === 'language') block.props = { display: 'code' };
// Leer eingefuegt: nichts steht auf der Seite, bis jemand Code eintraegt. // Leer eingefuegt: nichts steht auf der Seite, bis jemand Code eintraegt.
if (type === 'html') block.props = { html: '' }; if (type === 'html') block.props = { html: '' };
// Leer eingefuegt wie „Eigenes HTML“: auf der Website steht nichts, bis ein Video gewaehlt ist.
if (type === 'video') block.props = { src: '', mode: 'player', title: '' };
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' }; if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' }; if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 }; if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
@@ -197,6 +199,7 @@ export function upgradeForType(document, type) {
if (type === 'postnav') upgradeDocument(document, 18); if (type === 'postnav') upgradeDocument(document, 18);
if (type === 'legal') upgradeDocument(document, 19); if (type === 'legal') upgradeDocument(document, 19);
if (type === 'html') upgradeDocument(document, 24); if (type === 'html') upgradeDocument(document, 24);
if (type === 'video') upgradeDocument(document, 28);
if (type === 'language') upgradeDocument(document, 27); if (type === 'language') upgradeDocument(document, 27);
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6); if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7); if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
@@ -348,7 +351,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument // blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt // liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
// nirgends. Eine Zahl, an einer Stelle. // nirgends. Eine Zahl, an einer Stelle.
export const SCHEMA_VERSION = 27; export const SCHEMA_VERSION = 28;
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' }; export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
@@ -692,7 +695,7 @@ export function relevantProperty(block, key, property) {
if (property.group === 'menu') return block.type === 'menu'; if (property.group === 'menu') return block.type === 'menu';
if (property.group === 'icon') return block.type === 'button' && block.props?.icon !== 'none'; if (property.group === 'icon') return block.type === 'button' && block.props?.icon !== 'none';
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type); if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type); if (property.group === 'text' || key === 'textShadow') return !['image', 'video', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
if (key === 'columnWidths') return block.type === 'row'; if (key === 'columnWidths') return block.type === 'row';
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact', 'social'].includes(block.type); if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact', 'social'].includes(block.type);
return true; return true;
+55 -1
View File
@@ -1,5 +1,5 @@
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js'; import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
import { openMedia, openDocuments, createMediaPreview } from './media.js'; import { openMedia, openDocuments, openVideos, createMediaPreview } from './media.js';
import { openPageDialog } from './page-picker.js'; import { openPageDialog } from './page-picker.js';
import { createDesignPanel } from './design-panel.js'; import { createDesignPanel } from './design-panel.js';
import { renderButtonControls, setButtonDesign } from './button-design.js'; import { renderButtonControls, setButtonDesign } from './button-design.js';
@@ -549,6 +549,13 @@ function renderContent(block, panel) {
field('Höhe (px)', 'height', block.props.height, { number: true, help: 'Zwischen 150 und 1200.' }); field('Höhe (px)', 'height', block.props.height, { number: true, help: 'Zwischen 150 und 1200.' });
field('Hinweis im Platzhalter', 'hint', block.props.hint || '', { multiline: true, help: 'Leer lassen für den Standardtext.' }); field('Hinweis im Platzhalter', 'hint', block.props.hint || '', { multiline: true, help: 'Leer lassen für den Standardtext.' });
} }
if (block.type === 'video') {
videoChoice(panel, block, 'src', 'Video', 'MP4 mit H.264 spielt jeder Browser ab.');
videoChoice(panel, block, 'srcWebm', 'Zweitformat WebM (optional)', 'Meist kleiner als das MP4. Browser, die es können, nehmen es zuerst.', 'video/webm');
posterChoice(panel, block);
field('Verhalten', 'mode', block.props.mode || 'player', { choices: { player: 'Spieler mit Steuerung', loop: 'Schleife ohne Ton' }, help: 'Die Schleife startet von selbst, sobald das Video zu sehen ist, und läuft nur stumm. Wer „Bewegung reduzieren“ eingestellt hat, sieht das Standbild.' });
field('Titel', 'title', block.props.title || '', { maxLength: 160, help: 'Worum es im Video geht. Screenreader lesen das vor. Bei einer rein dekorativen Schleife leer lassen.' });
}
if (block.type === 'html') { if (block.type === 'html') {
// Rohes Markup. Dieselbe Rolle wie beim eigenen Skript, deshalb dieselbe Anzeige: sichtbar // Rohes Markup. Dieselbe Rolle wie beim eigenen Skript, deshalb dieselbe Anzeige: sichtbar
// fuer alle, beschreibbar nur fuer Administratoren - und der Server weist es ohnehin ab. // fuer alle, beschreibbar nur fuer Administratoren - und der Server weist es ohnehin ab.
@@ -671,6 +678,8 @@ function renderContent(block, panel) {
panel.append(images); panel.append(images);
} }
if (block.type === 'section') { if (block.type === 'section') {
videoChoice(panel, block, 'backgroundVideo', 'Hintergrundvideo', 'Läuft stumm in der Schleife hinter dem Inhalt. Das Hintergrundbild unter Design bleibt das Standbild, bis das Video läuft, und für alle, die keine Bewegung möchten. Eine Überlagerung liegt nicht über dem Video.');
videoChoice(panel, block, 'backgroundVideoWebm', 'Zweitformat WebM (optional)', 'Meist kleiner als das MP4. Browser, die es können, nehmen es zuerst.', 'video/webm');
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } }); field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Websitebreite (aus dem Theme)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos nutzt die gesamte Sektion. Text und Überschriften füllen ihren Container; eine eigene Begrenzung stellst du unter Design → Größe ein.' }); field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Websitebreite (aus dem Theme)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos nutzt die gesamte Sektion. Text und Überschriften füllen ihren Container; eine eigene Begrenzung stellst du unter Design → Größe ein.' });
} }
@@ -866,6 +875,51 @@ function renderPluginFields(block, panel, plugin, field) {
} }
} }
/** Ein Videofeld im Eigenschaftenfeld. Im Dokument steht die Adresse der Datei. */
function videoChoice(panel, block, key, labelText, helpText, only = '') {
const value = block.props?.[key] || '';
const wrap = document.createElement('div'); wrap.className = 'plugin-media-field';
const label = document.createElement('p'); label.className = 'field-label'; label.textContent = labelText; wrap.append(label);
const set = src => {
history.change(doc => {
upgradeDocument(doc, 28);
const current = locate(doc.blocks, block.id)?.block;
if (!current) return;
current.props ??= {};
if (src) current.props[key] = src; else delete current.props[key];
});
renderProperties(); changed();
};
if (value) { const name = document.createElement('code'); name.textContent = value; wrap.append(name); }
const pick = document.createElement('button'); pick.type = 'button'; pick.className = 'secondary'; pick.textContent = value ? 'Anderes Video wählen' : 'Video wählen oder hochladen';
pick.addEventListener('click', () => openVideos({ only, onChoose: item => set(item.src) }));
wrap.append(pick);
if (value) { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Video entfernen'; remove.addEventListener('click', () => set('')); wrap.append(remove); }
const help = document.createElement('p'); help.className = 'field-help'; help.textContent = helpText; wrap.append(help);
panel.append(wrap);
}
/** Das Standbild eines Videos: ein Bild der Mediathek, in einer Breite, die für ein Video reicht. */
function posterChoice(panel, block) {
const value = block.props?.poster || '';
const wrap = document.createElement('div'); wrap.className = 'plugin-media-field';
const label = document.createElement('p'); label.className = 'field-label'; label.textContent = 'Standbild (optional)'; wrap.append(label);
const preview = createMediaPreview({ src: value }); wrap.append(preview.element);
const set = src => {
history.change(doc => {
const current = locate(doc.blocks, block.id)?.block;
if (!current) return;
if (src) current.props.poster = src; else delete current.props.poster;
});
renderProperties(); changed();
};
const pick = document.createElement('button'); pick.type = 'button'; pick.className = 'secondary'; pick.textContent = value ? 'Anderes Bild wählen' : 'Bild wählen';
pick.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: ([item]) => { if (item) set((item.variants.find(variant => variant.width >= 1600) ?? item.variants.at(-1)).src); } }));
wrap.append(pick);
if (value) { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Bild entfernen'; remove.addEventListener('click', () => set('')); wrap.append(remove); }
panel.append(wrap);
}
function chooseMedia(multiple) { function chooseMedia(multiple) {
const target = selected; const target = selected;
openMedia({ multiple, onChoose: items => { openMedia({ multiple, onChoose: items => {
+1
View File
@@ -58,6 +58,7 @@ export const PREVIEWS = {
form: '<rect x="2.5" y="2" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="6.8" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="11.6" width="8" height="3.4" rx="1.2" fill="currentColor" stroke="none"/>', form: '<rect x="2.5" y="2" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="6.8" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="11.6" width="8" height="3.4" rx="1.2" fill="currentColor" stroke="none"/>',
legal: `${bar(2, 4, 20, 1.6)}${bar(2, 7.4, 16, 1.6)}${bar(2, 10.8, 11, 1.6)}`, legal: `${bar(2, 4, 20, 1.6)}${bar(2, 7.4, 16, 1.6)}${bar(2, 10.8, 11, 1.6)}`,
partialref: '<rect x="2.5" y="4.5" width="13" height="9" rx="1.5"/><path d="M6.5 4.5V2h13v9h-2.5"/>', partialref: '<rect x="2.5" y="4.5" width="13" height="9" rx="1.5"/><path d="M6.5 4.5V2h13v9h-2.5"/>',
video: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><path d="M10 5.4l5 2.6-5 2.6z"/>',
embed: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><path d="M10.2 5.6l4.6 2.4-4.6 2.4z" fill="currentColor" stroke="none"/>', embed: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><path d="M10.2 5.6l4.6 2.4-4.6 2.4z" fill="currentColor" stroke="none"/>',
}; };
+1 -1
View File
@@ -9,7 +9,7 @@ const zone = document.querySelector('[data-dropzone]');
const input = zone?.querySelector('input[type="file"]'); const input = zone?.querySelector('input[type="file"]');
const chosen = document.querySelector('[data-chosen]'); const chosen = document.querySelector('[data-chosen]');
if (zone && input && chosen) { if (zone && input && chosen) {
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']; const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf', 'video/mp4', 'video/webm'];
const describe = (files, ignored = 0) => { const describe = (files, ignored = 0) => {
if (!files.length) { if (!files.length) {
+65
View File
@@ -206,4 +206,69 @@ export function openDocuments({ onChoose } = {}) {
}).catch(err => { $('#document-status').textContent = ''; error(err.message); }); }).catch(err => { $('#document-status').textContent = ''; error(err.message); });
} }
/**
* Ein Video aus der Mediathek wählen - oder eines hochladen und gleich wählen.
*
* Wie bei den PDFs ein eigener, schmaler Dialog. Mit only: 'video/webm' bietet er nur dieses Format
* an, für das Zweitformat neben dem MP4.
*/
export function openVideos({ onChoose, only = '' } = {}) {
const opener = document.activeElement;
const dialog = element('dialog', '', 'media-dialog document-dialog');
dialog.setAttribute('aria-labelledby', 'video-dialog-title');
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">VIDEOS</p><h2 id="video-dialog-title">Video wählen</h2></div><button class="secondary" id="video-close">Schließen</button></div>
<p class="panel-hint">MP4 (H.264) oder WebM bis 64 MB. Das Video wird unverändert abgelegt und ist über seine Adresse öffentlich erreichbar. Kleine Dateien laden schneller – ein Hintergrund sollte nur wenige MB haben.</p>
<div class="media-tools"><div><label for="video-search">Videos suchen</label><input id="video-search" type="search" placeholder="Titel"></div><div><label for="video-upload">Video hochladen</label><input id="video-upload" type="file" accept="video/mp4,video/webm,.mp4,.webm" disabled></div></div>
<p id="video-status" role="status" aria-live="polite">Wird geladen …</p><p id="video-error" class="media-error" role="alert" hidden></p>
<div id="video-list" class="document-list" role="group" aria-label="Video auswählen"></div>
<div class="media-footer"><span></span><button id="video-insert" class="primary" disabled>Video übernehmen</button></div>`;
document.body.append(dialog);
const $ = selector => dialog.querySelector(selector);
let items = [], chosen = null, token;
const error = message => { $('#video-error').textContent = message; $('#video-error').hidden = !message; };
const size = bytes => `${(bytes / 1048576).toLocaleString('de', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} MB`;
const base = document.querySelector('meta[name="admin-base"]').content + '/api/videos';
const fits = item => !only || item.mime === only;
function render() {
const query = $('#video-search').value.toLocaleLowerCase('de');
const visible = items.filter(item => fits(item) && item.title.toLocaleLowerCase('de').includes(query));
$('#video-list').replaceChildren();
for (const item of visible) {
const card = element('button', '', 'document-card'); card.type = 'button';
card.setAttribute('aria-pressed', String(chosen?.id === item.id));
card.append(element('span', item.mime === 'video/webm' ? 'WEBM' : 'MP4', 'document-badge'), element('strong', item.title), element('span', size(item.bytes)));
card.addEventListener('click', () => { chosen = item; render(); card.focus(); });
card.addEventListener('dblclick', () => { chosen = item; $('#video-insert').click(); });
$('#video-list').append(card);
}
if (!visible.length) $('#video-list').append(element('p', items.some(fits) ? 'Kein Video mit diesem Titel.' : 'Noch keine passenden Videos. Lade oben eines hoch.', 'muted'));
$('#video-status').textContent = `${items.filter(fits).length} Videos${chosen ? ` · gewählt: ${chosen.title}` : ''}`;
$('#video-insert').disabled = !chosen;
}
$('#video-search').addEventListener('input', render);
$('#video-close').addEventListener('click', () => dialog.close());
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
$('#video-upload').addEventListener('change', async () => {
const file = $('#video-upload').files[0]; if (!file) return;
error('');
if (file.size > 64 * 1024 * 1024) { error(`${file.name}: größer als 64 MB`); $('#video-upload').value = ''; return; }
$('#video-status').textContent = `Wird hochgeladen: ${file.name}`;
try {
const form = new FormData(); form.append('file', file);
const response = await fetch(base + '/upload', { method: 'POST', headers: { 'X-CSRF-Token': token }, body: form });
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status}).`);
items.unshift(data.item); chosen = fits(data.item) ? data.item : null;
} catch (err) { error(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); }
$('#video-upload').value = ''; render();
});
$('#video-insert').addEventListener('click', () => { if (chosen) { onChoose?.(chosen); dialog.close(); } });
dialog.showModal();
fetch(base, { credentials: 'same-origin' }).then(async response => {
if (!response.ok || response.redirected) throw new Error('Videos konnten nicht geladen werden. Bitte neu anmelden.');
const data = await response.json(); items = data.items; token = data.csrfToken; $('#video-upload').disabled = false; render();
}).catch(err => { $('#video-status').textContent = ''; error(err.message); });
}
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia()); document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());
+2
View File
@@ -23,3 +23,5 @@
.editor-add-content{min-height:0;margin-top:12px;padding:10px;border-width:1px;background:#f3f8f4} .editor-add-content{min-height:0;margin-top:12px;padding:10px;border-width:1px;background:#f3f8f4}
.block-accordion.editor-container>.editor-add-content{margin-top:16px} .block-accordion.editor-container>.editor-add-content{margin-top:16px}
@media(max-width:767px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}} @media(max-width:767px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
/* Die Vorschau läuft ohne Skript; dann blendet Chrome bei jedem Video die Steuerung ein. */
.section-video::-webkit-media-controls{display:none!important}
+10
View File
@@ -241,6 +241,16 @@ body.consent-open{padding-bottom:320px}
.embed-provider{margin:0 0 16px;font-size:13px;color:var(--muted)} .embed-provider{margin:0 0 16px;font-size:13px;color:var(--muted)}
/* Eigenes HTML: nur so viel Regel, dass fremdes Markup die Spalte nicht sprengt. Alles /* Eigenes HTML: nur so viel Regel, dass fremdes Markup die Spalte nicht sprengt. Alles
Weitere bestimmt der Code selbst - das ist der Zweck des Bausteins. */ Weitere bestimmt der Code selbst - das ist der Zweck des Bausteins. */
.block-video{position:relative;margin:0;max-width:100%}
.video-media{display:block;width:100%;height:auto;border-radius:10px}
.has-video{position:relative;overflow:hidden}
.section-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none}
.has-video>.section-inner{position:relative;z-index:1}
.video-toggle{position:absolute;right:16px;bottom:16px;z-index:2;width:44px;height:44px;padding:0;border:0;border-radius:50%;background:rgba(10,20,35,.72);color:#fff;font:14px/1 system-ui,sans-serif;cursor:pointer}
.video-toggle::before{content:"❚❚"}
.video-toggle.is-paused::before{content:"▶"}
.video-toggle:focus-visible{outline:3px solid #fff;outline-offset:2px}
.video-toggle[hidden]{display:none}
.block-html{max-width:100%} .block-html{max-width:100%}
.block-html img,.block-html video,.block-html table{max-width:100%} .block-html img,.block-html video,.block-html table{max-width:100%}
.block-html table{border-collapse:collapse} .block-html table{border-collapse:collapse}
+1
View File
@@ -26,6 +26,7 @@ export const FIELDS = {
icon: [{ prop: 'label', label: 'Beschriftung', max: 120 }], icon: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
progress: [{ prop: 'label', label: 'Beschriftung', max: 120 }], progress: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
embed: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'hint', label: 'Hinweis', max: 300, multiline: true }], embed: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'hint', label: 'Hinweis', max: 300, multiline: true }],
video: [{ prop: 'title', label: 'Titel', max: 160 }],
}; };
/** Felder der Seite selbst. `seoTitle` hat kein Gegenstück im Dokument der Hauptsprache. */ /** Felder der Seite selbst. `seoTitle` hat kein Gegenstück im Dokument der Hauptsprache. */
+34
View File
@@ -0,0 +1,34 @@
/*
* Schleifenvideos: im Hintergrund einer Sektion oder im Video-Baustein.
*
* Abgespielt wird erst von hier aus, nicht mit autoplay im HTML. So lädt die Seite zuerst ihren
* Inhalt, das Video folgt danach (preload="none"), und es läuft nur, solange es zu sehen ist.
* Wer „Bewegung reduzieren“ eingestellt hat oder Daten sparen will, bekommt das Standbild und kann
* das Video selbst starten. Der Knopf gehört dazu: Bewegung, die länger als fünf Sekunden läuft,
* muss sich anhalten lassen (WCAG 2.2.2). Ohne Skript bleibt das Standbild.
*/
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const saving = navigator.connection?.saveData === true;
for (const video of document.querySelectorAll('[data-video-loop]')) {
const toggle = video.parentElement.querySelector(':scope > .video-toggle');
let wanted = !reduced.matches && !saving;
let visible = false;
const paint = () => {
toggle.classList.toggle('is-paused', !wanted);
toggle.setAttribute('aria-label', wanted ? toggle.dataset.pause : toggle.dataset.play);
};
const sync = () => {
if (!wanted || !visible) { video.pause(); return; }
video.play().catch(error => {
// Ein Anhalten mitten im Starten ist kein Fehler. Verweigert der Browser das Abspielen,
// etwa im Stromsparmodus, zeigt der Knopf die Wahrheit.
if (error.name === 'NotAllowedError') { wanted = false; paint(); }
});
};
toggle.hidden = false;
toggle.addEventListener('click', () => { wanted = !wanted; paint(); sync(); });
reduced.addEventListener('change', () => { if (reduced.matches) { wanted = false; paint(); sync(); } });
new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; sync(); }).observe(video);
paint();
}
+25 -2
View File
@@ -43,8 +43,10 @@ final class DocumentValidator
public const V24_TYPES = ['html']; public const V24_TYPES = ['html'];
/** Schema v27 (ADR 111): die Sprachwahl, gefüllt aus den Sprachen, in denen die Seite vorliegt. */ /** Schema v27 (ADR 111): die Sprachwahl, gefüllt aus den Sprachen, in denen die Seite vorliegt. */
public const V27_TYPES = ['language']; public const V27_TYPES = ['language'];
/** Schema v28: ein eigenes Video aus der Mediathek. */
public const V28_TYPES = ['video'];
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */ /** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
public const SCHEMA_VERSION = 27; public const SCHEMA_VERSION = 28;
/** /**
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a * @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
@@ -155,7 +157,7 @@ final class DocumentValidator
// page: a deactivated plugin must never make stored content unreadable. Nothing from // page: a deactivated plugin must never make stored content unreadable. Nothing from
// props is rendered for such a block, so unvalidated values never reach the output. // props is rendered for such a block, so unvalidated values never reach the output.
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type); $plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || ($schemaVersion >= 27 && in_array($type, self::V27_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.'); $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 28 && in_array($type, self::V28_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || ($schemaVersion >= 27 && in_array($type, self::V27_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
// An accordion entry only ever renders inside an accordion; at the top level or anywhere // An accordion entry only ever renders inside an accordion; at the top level or anywhere
// else it would produce a details element without its group. // else it would produce a details element without its group.
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.'); $this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
@@ -468,6 +470,22 @@ final class DocumentValidator
$this->require(null === parse_url($url, PHP_URL_PORT) && null === parse_url($url, PHP_URL_USER), 'Die Adresse darf keinen Port und keine Zugangsdaten enthalten.'); $this->require(null === parse_url($url, PHP_URL_PORT) && null === parse_url($url, PHP_URL_USER), 'Die Adresse darf keinen Port und keine Zugangsdaten enthalten.');
$this->require(is_int($props['height'] ?? null) && $props['height'] >= 150 && $props['height'] <= 1200, 'Die Höhe liegt zwischen 150 und 1200 Pixeln.'); $this->require(is_int($props['height'] ?? null) && $props['height'] >= 150 && $props['height'] <= 1200, 'Die Höhe liegt zwischen 150 und 1200 Pixeln.');
} }
if ('video' === $type) {
// Nur eigene Dateien: die Richtlinie der Seite erlaubt Medien von der eigenen Adresse,
// und ein fremdes Video wäre ohnehin ein Fall für „Externer Inhalt“ mit Einwilligung.
$this->videoPath($props['src'] ?? '');
$this->videoPath($props['srcWebm'] ?? '');
if ('' !== ($props['poster'] ?? '')) {
$this->url($props['poster']);
}
$this->text($props['title'] ?? '', 160, true);
$this->require(in_array($props['mode'] ?? 'player', ['player', 'loop'], true), 'Ein Video ist ein Spieler oder eine Schleife.');
}
if ('section' === $type && (isset($props['backgroundVideo']) || isset($props['backgroundVideoWebm']))) {
$this->require($schemaVersion >= 28, 'Ein Hintergrundvideo braucht Schema v28.');
$this->videoPath($props['backgroundVideo'] ?? '');
$this->videoPath($props['backgroundVideoWebm'] ?? '');
}
if ('html' === $type) { if ('html' === $type) {
// Eigenes HTML geht unveraendert auf die Seite. Geprueft wird es hier, damit ein // Eigenes HTML geht unveraendert auf die Seite. Geprueft wird es hier, damit ein
// unbrauchbarer Wert beim Speichern eine Meldung bekommt statt beim Ausliefern // unbrauchbarer Wert beim Speichern eine Meldung bekommt statt beim Ausliefern
@@ -567,6 +585,11 @@ final class DocumentValidator
$this->require(is_string($value) && strlen($value) <= $max && ($emptyAllowed || '' !== trim($value)), 'Missing or oversized text.'); $this->require(is_string($value) && strlen($value) <= $max && ($emptyAllowed || '' !== trim($value)), 'Missing or oversized text.');
} }
private function videoPath(mixed $path): void
{
$this->require('' === $path || (is_string($path) && 1 === preg_match(VideoLibrary::PATH, $path)), 'Ein Video kommt aus der Mediathek.');
}
private function url(mixed $url): void private function url(mixed $url): void
{ {
$this->require(is_string($url) && strlen($url) <= 2048, 'Invalid URL.'); $this->require(is_string($url) && strlen($url) <= 2048, 'Invalid URL.');
+1
View File
@@ -47,6 +47,7 @@ final class Translations
'icon' => ['label'], 'icon' => ['label'],
'progress' => ['label'], 'progress' => ['label'],
'embed' => ['title', 'hint'], 'embed' => ['title', 'hint'],
'video' => ['title'],
]; ];
/** Felder der Seite selbst und ihre Höchstlänge. */ /** Felder der Seite selbst und ihre Höchstlänge. */
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace App\Content;
use App\Domain\StaleWrite;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Videos neben den Bildern und PDFs der Mediathek.
*
* Wie ein PDF wird ein Video nicht umgerechnet, sondern so ausgeliefert, wie es kam: Auf dem
* Hosting gibt es kein ffmpeg, und PHP könnte das in der Laufzeit einer Anfrage auch nicht. Deshalb
* die Prüfung am Dateianfang statt an der Endung und beim Ausliefern Kopfzeilen, die den Browser
* nichts anderes darin sehen lassen als ein Video.
*
* Die Dateien liegen im Medienordner, als <id>.mp4 oder <id>.webm - so gehen sie mit jeder
* Sicherung und jedem Website-Paket mit.
*/
final class VideoLibrary
{
/** Ein Hintergrund oder ein kurzer Film. Für Längeres ist ein Videodienst der bessere Weg. */
public const MAX_BYTES = 64 * 1024 * 1024;
/** Die Adresse eines Videos, so steht sie im Dokument. */
public const PATH = '#^/media/[a-f0-9]{32}\.(?:mp4|webm)$#D';
private const EXTENSIONS = ['video/mp4' => 'mp4', 'video/webm' => 'webm'];
public function __construct(private Connection $db, private string $directory)
{
}
/** @return list<array<string, mixed>> neueste zuerst */
public function list(bool $withArchived = true): array
{
return array_map($this->present(...), $this->db->fetchAllAssociative(
'SELECT * FROM video'.($withArchived ? '' : ' WHERE archived = 0').' ORDER BY created_at DESC, id DESC',
));
}
public function find(string $id): ?array
{
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
return null;
}
$row = $this->db->fetchAssociative('SELECT * FROM video WHERE id = ?', [$id]);
return $row ? $this->present($row) : null;
}
/** Soll diese Datei als Video behandelt werden? Der Inhalt entscheidet, die Endung ist Rückfall. */
public function claims(UploadedFile $file): bool
{
return null !== $this->mimeOf($file->getPathname())
|| str_starts_with((string) $file->getMimeType(), 'video/')
|| in_array(strtolower($file->getClientOriginalExtension()), ['mp4', 'm4v', 'webm', 'mov'], true);
}
public function upload(UploadedFile $file): array
{
if (!$file->isValid() || $file->getSize() > self::MAX_BYTES) {
throw new \InvalidArgumentException('Upload fehlgeschlagen. Maximal 64 MB pro Video; das PHP-Hosting kann ein kleineres Limit setzen.');
}
$mime = $this->mimeOf($file->getPathname());
if (null === $mime) {
throw new \InvalidArgumentException('Das ist kein abspielbares Video. Bitte eine MP4- oder WebM-Datei wählen; MOV spielen nicht alle Browser ab.');
}
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
}
$id = bin2hex(random_bytes(16));
$path = $this->path($id, self::EXTENSIONS[$mime]);
if (!@copy($file->getPathname(), $path) || !is_file($path)) {
throw new \RuntimeException('Das Video konnte nicht gespeichert werden.');
}
try {
$name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Video', 0, 160, 'UTF-8');
$row = ['id' => $id, 'title' => $title, 'mime' => $mime, 'bytes' => (int) filesize($path), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
$this->db->insert('video', $row);
} catch (\Throwable $error) {
unlink($path);
throw $error;
}
return $this->present($row);
}
public function update(string $id, array $data): array
{
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
throw new \InvalidArgumentException('Titel (max. 160 Bytes) und Version prüfen.');
}
$count = $this->db->executeStatement('UPDATE video SET title = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], (int) $data['archived'], $id, $data['version']]);
if (1 !== $count) {
throw new StaleWrite();
}
return $this->find($id) ?? throw new StaleWrite();
}
/** Die Datei eines Videos, oder null. Archivierte werden weiter ausgeliefert, wie Bilder. */
public function file(string $id, string $extension): ?string
{
$item = $this->find($id);
if (null === $item || $extension !== self::EXTENSIONS[$item['mime']]) {
return null;
}
$path = $this->path($id, $extension);
return is_file($path) ? $path : null;
}
/**
* Der Typ nach den ersten Bytes. MP4 trägt ab Byte 4 das Kästchen „ftyp“, WebM beginnt mit dem
* EBML-Kopf und nennt seinen Dokumenttyp gleich danach. MOV hat auch „ftyp“, aber die Marke
* „qt“ - das spielen nicht alle Browser ab, also wird es abgewiesen statt still zu scheitern.
*/
private function mimeOf(string $path): ?string
{
$head = (string) file_get_contents($path, false, null, 0, 4096);
if ('ftyp' === substr($head, 4, 4) && 'qt ' !== substr($head, 8, 4)) {
return 'video/mp4';
}
return str_starts_with($head, "\x1A\x45\xDF\xA3") && str_contains($head, 'webm') ? 'video/webm' : null;
}
private function path(string $id, string $extension): string
{
return $this->directory.'/'.$id.'.'.$extension;
}
private function present(array $row): array
{
return [
'id' => $row['id'],
'title' => $row['title'],
'mime' => $row['mime'],
'bytes' => (int) $row['bytes'],
'archived' => (bool) $row['archived'],
'version' => (int) $row['version'],
'created_at' => $row['created_at'],
'src' => '/media/'.$row['id'].'.'.self::EXTENSIONS[$row['mime']],
];
}
}
+77 -3
View File
@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Content\DocumentLibrary; use App\Content\DocumentLibrary;
use App\Content\MediaLibrary; use App\Content\MediaLibrary;
use App\Content\SiteSettings; use App\Content\SiteSettings;
use App\Content\VideoLibrary;
use App\Domain\StaleWrite; use App\Domain\StaleWrite;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -17,7 +18,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
final class MediaController extends AbstractController final class MediaController extends AbstractController
{ {
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents) public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents, private VideoLibrary $videos)
{ {
} }
@@ -29,7 +30,7 @@ final class MediaController extends AbstractController
$archived = $request->query->getBoolean('archiv'); $archived = $request->query->getBoolean('archiv');
$query = mb_substr(trim($request->query->getString('q')), 0, 160); $query = mb_substr(trim($request->query->getString('q')), 0, 160);
$library = $this->media->page($request->query->getInt('seite', 1), $archived, query: $query); $library = $this->media->page($request->query->getInt('seite', 1), $archived, query: $query);
$view = ['library' => $library, 'archived' => $archived, 'query' => $query, 'documents' => $this->documents->list($archived)]; $view = ['library' => $library, 'archived' => $archived, 'query' => $query, 'documents' => $this->documents->list($archived), 'videos' => $this->videos->list($archived)];
if ($request->isXmlHttpRequest()) { if ($request->isXmlHttpRequest()) {
return $this->json([ return $this->json([
'html' => $this->renderView('admin/media-results.html.twig', $view), 'html' => $this->renderView('admin/media-results.html.twig', $view),
@@ -57,7 +58,11 @@ final class MediaController extends AbstractController
try { try {
// Bilder und PDFs gehen durch dasselbe Feld. Welche Art es ist, entscheidet der // Bilder und PDFs gehen durch dasselbe Feld. Welche Art es ist, entscheidet der
// Inhalt der Datei; die Endung zaehlt nur, wenn der Inhalt nichts sagt. // Inhalt der Datei; die Endung zaehlt nur, wenn der Inhalt nichts sagt.
$this->isPdf($file) ? $this->documents->upload($file) : $this->media->upload($file); match (true) {
$this->isPdf($file) => $this->documents->upload($file),
$this->videos->claims($file) => $this->videos->upload($file),
default => $this->media->upload($file),
};
++$done; ++$done;
} catch (\InvalidArgumentException $error) { } catch (\InvalidArgumentException $error) {
return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Datei(en) davor wurden hochgeladen.', $done) : '')); return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Datei(en) davor wurden hochgeladen.', $done) : ''));
@@ -120,6 +125,31 @@ final class MediaController extends AbstractController
return $this->back($request, 'Die Angaben zum Dokument wurden gespeichert.', 'success'); return $this->back($request, 'Die Angaben zum Dokument wurden gespeichert.', 'success');
} }
#[Route('/%kant.admin_path%/media/video/{id}', name: 'admin_video_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
public function formUpdateVideo(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('media-form', $request->request->getString('_csrf_token'))) {
return $this->back($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if (!$this->videos->find($id)) {
throw $this->createNotFoundException();
}
try {
$this->videos->update($id, [
'title' => $request->request->getString('title'),
'archived' => $request->request->getBoolean('archived'),
'version' => $request->request->getInt('version'),
]);
} catch (StaleWrite) {
return $this->back($request, 'Das Video wurde inzwischen anderswo geändert. Lade die Seite neu und trage die Angaben erneut ein.');
} catch (\InvalidArgumentException $error) {
return $this->back($request, $error->getMessage());
}
return $this->back($request, 'Die Angaben zum Video wurden gespeichert.', 'success');
}
private function isPdf(UploadedFile $file): bool private function isPdf(UploadedFile $file): bool
{ {
$mime = $file->getMimeType(); $mime = $file->getMimeType();
@@ -195,6 +225,36 @@ final class MediaController extends AbstractController
} }
} }
/** Die Videos fuer die Auswahl im Editor. */
#[Route('/%kant.admin_path%/api/videos', methods: ['GET'])]
public function listVideos(): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
return $this->json(['items' => $this->videos->list(false), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
}
#[Route('/%kant.admin_path%/api/videos/upload', methods: ['POST'])]
public function uploadVideo(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('media', $request->headers->get('X-CSRF-Token'))) {
return $this->json(['error' => 'Sitzung abgelaufen. Bitte erneut anmelden.'], 403);
}
try {
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
throw new \InvalidArgumentException('Keine Datei empfangen. Bitte das PHP-Uploadlimit und die Dateigröße prüfen.');
}
return $this->json(['item' => $this->videos->upload($file)], 201);
} catch (\InvalidArgumentException $error) {
return $this->json(['error' => $error->getMessage()], 422);
} catch (\RuntimeException) {
return $this->json(['error' => 'Das Video konnte nicht gespeichert werden. Schreibrechte des Medienordners prüfen.'], 503);
}
}
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])] #[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
public function read(string $id): Response public function read(string $id): Response
{ {
@@ -324,6 +384,20 @@ final class MediaController extends AbstractController
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/webp', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]); return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/webp', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
} }
/**
* Ein Video, so wie es hochgeladen wurde.
*
* BinaryFileResponse beantwortet Range-Anfragen; ohne sie lässt Safari ein Video gar nicht
* abspielen. Die Datei ändert sich nie, ihre Adresse ist an die Kennung gebunden.
*/
#[Route('/media/{id}.{extension}', name: 'video_serve', requirements: ['id' => '[a-f0-9]{32}', 'extension' => 'mp4|webm'], methods: ['GET'])]
public function serveVideo(string $id, string $extension): Response
{
$path = $this->videos->file($id, $extension) ?? throw $this->createNotFoundException();
return new BinaryFileResponse($path, headers: ['Content-Type' => 'video/'.$extension, 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
}
/** /**
* Ein PDF, so wie es hochgeladen wurde. * Ein PDF, so wie es hochgeladen wurde.
* *
+18 -1
View File
@@ -313,6 +313,7 @@ final class PageController extends AbstractController
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)]; $documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider')); $needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc)); $needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
$needsVideo = (bool) array_filter($documents, fn (array $doc): bool => $this->hasLoopVideo($doc['blocks'] ?? []));
// Tabs und vergroesserbare Bilder koennen auch im Kopf, im Fuss oder in einer verknuepften // Tabs und vergroesserbare Bilder koennen auch im Kopf, im Fuss oder in einer verknuepften
// Sektion stehen - das Skript und die Richtlinie dafuer richten sich nach allen Dokumenten. // Sektion stehen - das Skript und die Richtlinie dafuer richten sich nach allen Dokumenten.
$needsTabs = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'tabs')); $needsTabs = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'tabs'));
@@ -404,6 +405,7 @@ final class PageController extends AbstractController
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der // Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
// dafür ein Skript braucht - nach demselben Muster wie die Tabs. // dafür ein Skript braucht - nach demselben Muster wie die Tabs.
'needsSlider' => $needsSlider, 'needsSlider' => $needsSlider,
'needsVideo' => $needsVideo,
'needsMotion' => $needsMotion, 'needsMotion' => $needsMotion,
'needsScrolled' => $needsScrolled, 'needsScrolled' => $needsScrolled,
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die // Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
@@ -442,7 +444,7 @@ final class PageController extends AbstractController
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur // Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website. // dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
$mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form')); $mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form'));
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || $needsConsent || [] !== $this->code->scriptsOf($page['document'])); $wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsVideo || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || $needsConsent || [] !== $this->code->scriptsOf($page['document']));
$frames = $embeds['hosts']; $frames = $embeds['hosts'];
if ($mitTurnstile) { if ($mitTurnstile) {
$frames[] = 'challenges.cloudflare.com'; $frames[] = 'challenges.cloudflare.com';
@@ -860,6 +862,21 @@ final class PageController extends AbstractController
return $walk($document['blocks'] ?? []); return $walk($document['blocks'] ?? []);
} }
/** Schleifenvideos brauchen video.js: es startet sie und gibt ihnen den Knopf zum Anhalten. */
private function hasLoopVideo(array $blocks): bool
{
foreach ($blocks as $block) {
$props = $block['props'] ?? [];
$loop = 'video' === ($block['type'] ?? null) && 'loop' === ($props['mode'] ?? null) && '' !== ($props['src'] ?? '');
$background = 'section' === ($block['type'] ?? null) && ('' !== ($props['backgroundVideo'] ?? '') || '' !== ($props['backgroundVideoWebm'] ?? ''));
if ($loop || $background || $this->hasLoopVideo($block['children'] ?? [])) {
return true;
}
}
return false;
}
private function hasType(array $document, string $type): bool private function hasType(array $document, string $type): bool
{ {
$found = false; $found = false;
+4 -2
View File
@@ -29,13 +29,13 @@ final class SitePackage
public const MEDIA = 'medien/'; public const MEDIA = 'medien/';
/** Was ersetzt wird, in der Reihenfolge des Einfügens (revision gehört zu page). */ /** Was ersetzt wird, in der Reihenfolge des Einfügens (revision gehört zu page). */
public const CONTENT_TABLES = ['page', 'revision', 'partial', 'menu', 'media', 'document', 'form', 'redirect', 'term', 'page_term', 'page_translation']; public const CONTENT_TABLES = ['page', 'revision', 'partial', 'menu', 'media', 'document', 'video', 'form', 'redirect', 'term', 'page_term', 'page_translation'];
/** Einstellungen dieser Installation, die ein Paket nie überschreibt: Zugangsdaten zu Diensten. */ /** Einstellungen dieser Installation, die ein Paket nie überschreibt: Zugangsdaten zu Diensten. */
public const KEEP_SETTINGS = ['turnstile', 'turnstileSiteKey', 'turnstileSecret']; public const KEEP_SETTINGS = ['turnstile', 'turnstileSiteKey', 'turnstileSecret'];
/** Dateinamen im Medienordner: Bildvarianten, PDFs, erzeugte Seitensymbole. Sonst nichts. */ /** Dateinamen im Medienordner: Bildvarianten, PDFs, erzeugte Seitensymbole. Sonst nichts. */
public const MEDIA_NAME = '/^(?:[a-f0-9]{32}(?:-\d{1,5})?\.(?:webp|pdf)|favicon-\d{1,4}\.png)$/D'; public const MEDIA_NAME = '/^(?:[a-f0-9]{32}(?:-\d{1,5})?\.(?:webp|pdf|mp4|webm)|favicon-\d{1,4}\.png)$/D';
private const MAX_FILES = 5000; private const MAX_FILES = 5000;
private const MAX_FILE_BYTES = 64 * 1024 * 1024; private const MAX_FILE_BYTES = 64 * 1024 * 1024;
@@ -322,6 +322,8 @@ final class SitePackage
} }
} }
} }
// Pakete von vor Schema v28 kennen die Tabelle noch nicht; sie sind trotzdem vollständig.
$tables['video'] ??= [];
foreach (self::CONTENT_TABLES as $table) { foreach (self::CONTENT_TABLES as $table) {
if (!isset($tables[$table])) { if (!isset($tables[$table])) {
throw new InvalidPage(sprintf('Im Paket fehlt die Tabelle „%s“.', $table)); throw new InvalidPage(sprintf('Im Paket fehlt die Tabelle „%s“.', $table));
+48 -4
View File
@@ -3,7 +3,7 @@
{% block title %}Mediathek{% endblock %} {% block title %}Mediathek{% endblock %}
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script><script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %} {% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script><script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
{% block heading %}Mediathek{% endblock %} {% block heading %}Mediathek{% endblock %}
{% block lead %}Alle Bilder und PDFs, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus. PDFs bleiben, wie sie sind, und lassen sich in jedem Linkziel wählen.{% endblock %} {% block lead %}Alle Bilder, PDFs und Videos, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus. PDFs und Videos bleiben, wie sie sind. PDFs lassen sich in jedem Linkziel wählen, Videos im Video-Baustein und als Hintergrund einer Sektion.{% endblock %}
{% block content %} {% block content %}
{% if is_granted('ROLE_EDITOR') %} {% if is_granted('ROLE_EDITOR') %}
@@ -19,13 +19,13 @@
<div class="dropzone" data-dropzone> <div class="dropzone" data-dropzone>
<label class="dropzone-label" for="files"> <label class="dropzone-label" for="files">
<span class="dropzone-icon" aria-hidden="true">▩</span> <span class="dropzone-icon" aria-hidden="true">▩</span>
<strong>Bilder oder PDFs hierher ziehen</strong> <strong>Bilder, PDFs oder Videos hierher ziehen</strong>
<span class="dropzone-hint">oder klicken, um Dateien zu wählen</span> <span class="dropzone-hint">oder klicken, um Dateien zu wählen</span>
</label> </label>
<input id="files" name="files[]" type="file" accept="image/jpeg,image/png,image/webp,application/pdf,.pdf" multiple class="dropzone-input"> <input id="files" name="files[]" type="file" accept="image/jpeg,image/png,image/webp,application/pdf,.pdf,video/mp4,video/webm,.mp4,.webm" multiple class="dropzone-input">
</div> </div>
<p class="dropzone-chosen" data-chosen role="status">Noch keine Datei gewählt.</p> <p class="dropzone-chosen" data-chosen role="status">Noch keine Datei gewählt.</p>
<p class="field-help">JPEG, PNG oder WebP bis 8 MB je Bild, PDF bis 20 MB. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen. Hochgeladene Dateien sind über ihre Adresse öffentlich – nichts Vertrauliches hochladen.</p> <p class="field-help">JPEG, PNG oder WebP bis 8 MB je Bild, PDF bis 20 MB, MP4 oder WebM bis 64 MB. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen. Hochgeladene Dateien sind über ihre Adresse öffentlich – nichts Vertrauliches hochladen.</p>
<button class="primary" type="submit">Hochladen</button> <button class="primary" type="submit">Hochladen</button>
</form> </form>
{% endif %} {% endif %}
@@ -39,6 +39,50 @@
{% include 'admin/search-feedback.html.twig' %} {% include 'admin/search-feedback.html.twig' %}
<div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/media-results.html.twig' %}</div> <div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/media-results.html.twig' %}</div>
<section class="stack" aria-labelledby="videos-heading">
<div class="section-head">
<h2 id="videos-heading">Videos <span class="count">{{ videos|length }}</span></h2>
</div>
{% if videos is empty %}
<div class="empty">
<h3>Keine Videos in der Mediathek.</h3>
<p>{{ is_granted('ROLE_EDITOR') ? 'Lade oben ein MP4 oder WebM hoch. Danach lässt es sich im Video-Baustein oder als Hintergrund einer Sektion wählen.' : 'Ein Redakteur kann Videos hochladen.' }}</p>
</div>
{% else %}
<div class="rows">
{% for item in videos %}
<article class="row row-stack">
<div class="row-line">
<span class="row-icon row-icon-text" aria-hidden="true">{{ item.mime == 'video/webm' ? 'WEBM' : 'MP4' }}</span>
<div class="row-main">
<h3><a href="{{ item.src }}" target="_blank" rel="noopener">{{ item.title }}</a></h3>
<p class="muted">{{ (item.bytes / 1048576)|number_format(1, ',') }} MB · <code>{{ item.src }}</code>{% if item.archived %} · archiviert{% endif %}</p>
</div>
</div>
{% if is_granted('ROLE_EDITOR') %}
<details class="sheet">
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
<div class="sheet-body">
<form action="{{ path('admin_video_save', {id: item.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
<input type="hidden" name="version" value="{{ item.version }}">
<input type="hidden" name="seite" value="{{ library.page }}"><input type="hidden" name="q" value="{{ query }}">
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
<label for="video-title-{{ item.id }}">Titel</label>
<input id="video-title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
<label class="check-label" for="video-archived-{{ item.id }}"><input id="video-archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
<p class="field-help">Archivierte Videos erscheinen nicht mehr in der Auswahl, bleiben aber erreichbar – eine Seite, die das Video zeigt, verliert es nicht.</p>
<button class="primary" type="submit">Angaben speichern</button>
</form>
</div>
</details>
{% endif %}
</article>
{% endfor %}
</div>
{% endif %}
</section>
{# Die PDFs stehen unter den Bildern und ohne Suche: es sind wenige, und ein Titel reicht, um {# Die PDFs stehen unter den Bildern und ohne Suche: es sind wenige, und ein Titel reicht, um
eines wiederzufinden. Der Schalter für das Archiv oben gilt auch hier. #} eines wiederzufinden. Der Schalter für das Archiv oben gilt auch hier. #}
<section class="stack" aria-labelledby="documents-heading"> <section class="stack" aria-labelledby="documents-heading">
+17 -1
View File
@@ -8,11 +8,16 @@
{% for key, value in layout.effects|default({}) %}{% set designClasses = designClasses ~ ' fx-' ~ key ~ '-' ~ value %}{% endfor %} {% for key, value in layout.effects|default({}) %}{% set designClasses = designClasses ~ ' fx-' ~ key ~ '-' ~ value %}{% endfor %}
{% if type in ['section', 'row', 'column'] %} {% if type in ['section', 'row', 'column'] %}
{% set tag = type == 'section' ? 'section' : 'div' %} {% set tag = type == 'section' ? 'section' : 'div' %}
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}{{ block.anchor|default('') ? ' has-anchor' : '' }}"> {% set backgroundVideo = type == 'section' ? props.backgroundVideo|default('') : '' %}
{% set backgroundWebm = type == 'section' ? props.backgroundVideoWebm|default('') : '' %}
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}{{ block.anchor|default('') ? ' has-anchor' : '' }}{{ backgroundVideo or backgroundWebm ? ' has-video' : '' }}">
{% if block.anchor|default('') %}<span id="{{ block.anchor }}" class="block-anchor" aria-hidden="true"></span>{% endif %} {% if block.anchor|default('') %}<span id="{{ block.anchor }}" class="block-anchor" aria-hidden="true"></span>{% endif %}
{% if backgroundVideo or backgroundWebm %}{# Dekoration: Die Sektion behält ihr Hintergrundbild, es bleibt stehen, bis das Video läuft. #}
<video class="section-video" muted loop playsinline preload="{{ editing|default(false) ? 'metadata' : 'none' }}" aria-hidden="true" tabindex="-1"{{ editing|default(false) ? '' : ' data-video-loop' }}>{% if backgroundWebm %}<source src="{{ backgroundWebm }}" type="video/webm">{% endif %}{% if backgroundVideo %}<source src="{{ backgroundVideo }}" type="{{ backgroundVideo ends with '.webm' ? 'video/webm' : 'video/mp4' }}">{% endif %}</video>{% endif %}
{% if type == 'section' %}<div class="shell section-inner">{% endif %} {% if type == 'section' %}<div class="shell section-inner">{% endif %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %} {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
{% if type == 'section' %}</div>{% endif %} {% if type == 'section' %}</div>{% endif %}
{% if (backgroundVideo or backgroundWebm) and not editing|default(false) %}{% include 'blocks/video-toggle.html.twig' only %}{% endif %}
</{{ tag }}> </{{ tag }}>
{% elseif type == 'heading' %} {% elseif type == 'heading' %}
<h{{ props.level }} id="{{ block.id }}" class="{{ designClasses|trim }}">{% if richHeadings|default(false) %}{{ props.text|raw }}{% else %}{{ props.text }}{% endif %}</h{{ props.level }}> <h{{ props.level }} id="{{ block.id }}" class="{{ designClasses|trim }}">{% if richHeadings|default(false) %}{{ props.text|raw }}{% else %}{{ props.text }}{% endif %}</h{{ props.level }}>
@@ -271,6 +276,17 @@
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% elseif type == 'video' %}
{% set source = props.src|default('') %}
{% if source == '' %}
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Video: unter Inhalt ein Video wählen.</p>{% endif %}
{% else %}
{% set looping = props.mode|default('player') == 'loop' %}
<figure id="{{ block.id }}" class="block-video{{ designClasses }}">
<video class="video-media"{% if looping %} muted loop playsinline preload="{{ editing|default(false) ? 'metadata' : 'none' }}"{{ editing|default(false) ? '' : ' data-video-loop' }}{% else %} controls playsinline preload="metadata"{% endif %}{% if props.poster|default('') %} poster="{{ props.poster }}"{% endif %}{% if props.title|default('') %} aria-label="{{ props.title }}"{% elseif looping %} aria-hidden="true"{% else %} aria-label="{{ 'Video'|kant_t }}"{% endif %}>{% if props.srcWebm|default('') %}<source src="{{ props.srcWebm }}" type="video/webm">{% endif %}<source src="{{ source }}" type="{{ source ends with '.webm' ? 'video/webm' : 'video/mp4' }}"></video>
{% if looping and not editing|default(false) %}{% include 'blocks/video-toggle.html.twig' only %}{% endif %}
</figure>
{% endif %}
{% elseif type == 'html' %} {% elseif type == 'html' %}
{# Markup des Betreibers, unveraendert. Geprueft wurde es beim Speichern (CustomCode::checkHtml), {# Markup des Betreibers, unveraendert. Geprueft wurde es beim Speichern (CustomCode::checkHtml),
setzen darf es nur ein Administrator - deshalb steht hier `raw` und keine Filterung. #} setzen darf es nur ein Administrator - deshalb steht hier `raw` und keine Filterung. #}
+2
View File
@@ -0,0 +1,2 @@
{# Sichtbar erst durch video.js - ohne Skript läuft auch nichts, das anzuhalten wäre. #}
<button class="video-toggle" type="button" hidden aria-label="{{ 'Video anhalten'|kant_t }}" data-pause="{{ 'Video anhalten'|kant_t }}" data-play="{{ 'Video abspielen'|kant_t }}"></button>
+1
View File
@@ -67,6 +67,7 @@
{% if needsTabs|default(false) %}<script src="/assets/tabs.js?v={{ kant_version() }}" defer></script>{% endif %} {% if needsTabs|default(false) %}<script src="/assets/tabs.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if needsFormScript|default(false) and not editing|default(false) %}<script src="/assets/form-steps.js?v={{ kant_version() }}" defer></script>{% endif %} {% if needsFormScript|default(false) and not editing|default(false) %}<script src="/assets/form-steps.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if needsSlider|default(false) %}<script type="module" src="/assets/slider.js?v={{ kant_version() }}"></script>{% endif %} {% if needsSlider|default(false) %}<script type="module" src="/assets/slider.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsVideo|default(false) and not editing|default(false) %}<script type="module" src="/assets/video.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsMotion|default(false) %}<script type="module" src="/assets/motion.js?v={{ kant_version() }}"></script>{% endif %} {% if needsMotion|default(false) %}<script type="module" src="/assets/motion.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsScrolled|default(false) %}<script type="module" src="/assets/scrolled.js?v={{ kant_version() }}"></script>{% endif %} {% if needsScrolled|default(false) %}<script type="module" src="/assets/scrolled.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsZoom|default(false) or needsDocument|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %} {% if needsZoom|default(false) or needsDocument|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %}
+90
View File
@@ -24,6 +24,7 @@ use App\Content\StyleCompiler;
use App\Content\StyleSchema; use App\Content\StyleSchema;
use App\Content\Theme; use App\Content\Theme;
use App\Content\Turnstile; use App\Content\Turnstile;
use App\Content\VideoLibrary;
use App\Domain\InvalidDocument; use App\Domain\InvalidDocument;
use App\Domain\InvalidMenu; use App\Domain\InvalidMenu;
use App\Domain\InvalidPage; use App\Domain\InvalidPage;
@@ -1399,6 +1400,95 @@ final class CmsTest extends TestCase
self::assertStringContainsString('<script src="/assets/lightbox.js?v=', $html); self::assertStringContainsString('<script src="/assets/lightbox.js?v=', $html);
} }
public function testAVideoFromTheLibraryPlaysAsBlockAndAsSectionBackground(): void
{
// Umut: „Baust du video embedding gleich ein" - der Hero von kant-it.de soll ein Video
// bekommen, ohne dass dafür Dateien von Hand auf den Server müssen.
$videos = new VideoLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-video-');
try {
file_put_contents($file, '<html><script>alert(1)</script></html>');
try {
$videos->upload(new UploadedFile($file, 'film.mp4', test: true));
self::fail('Eine Datei, die kein Video ist, darf nicht als Video durchgehen.');
} catch (\InvalidArgumentException) {
}
file_put_contents($file, "\0\0\0\x14ftypqt \0\0\0\0qt ".str_repeat("\0", 64));
try {
$videos->upload(new UploadedFile($file, 'film.mov', test: true));
self::fail('MOV spielen nicht alle Browser ab und wird deshalb abgewiesen.');
} catch (\InvalidArgumentException) {
}
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM video'));
file_put_contents($file, "\0\0\0\x18ftypmp42\0\0\0\0mp42isom".str_repeat('x', 2000));
$item = $videos->upload(new UploadedFile($file, 'Hero Loop.mp4', test: true));
file_put_contents($file, "\x1A\x45\xDF\xA3\x9F\x42\x86\x81\x01\x42\x82\x84webm".str_repeat('y', 2000));
$webm = $videos->upload(new UploadedFile($file, 'hero.bin', test: true));
} finally {
@unlink($file);
}
self::assertSame('Hero Loop', $item['title']);
self::assertSame('/media/'.$item['id'].'.mp4', $item['src']);
self::assertSame('/media/'.$webm['id'].'.webm', $webm['src']);
$antwort = $this->request($item['src']);
self::assertSame(200, $antwort->getStatusCode());
self::assertSame('video/mp4', $antwort->headers->get('Content-Type'));
self::assertSame('nosniff', $antwort->headers->get('X-Content-Type-Options'));
self::assertSame("default-src 'none'", $antwort->headers->get('Content-Security-Policy'));
self::assertSame('bytes', $antwort->headers->get('Accept-Ranges'));
// Dieselbe Kennung unter der falschen Endung gibt es nicht.
self::assertSame(404, $this->request('/media/'.$item['id'].'.webm')->getStatusCode());
self::assertSame(404, $this->request('/media/'.str_repeat('a', 32).'.mp4')->getStatusCode());
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = DocumentValidator::SCHEMA_VERSION;
$document['blocks'][0]['props'] = ['backgroundVideo' => $item['src'], 'backgroundVideoWebm' => $webm['src']];
$document['blocks'][] = ['id' => 'film', 'type' => 'video', 'props' => ['src' => $item['src'], 'mode' => 'player', 'title' => 'Unser Betrieb', 'poster' => '/media/'.str_repeat('b', 32).'/1600']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$seite = $this->request('/');
$html = $seite->getContent();
self::assertStringContainsString('has-video', $html);
self::assertMatchesRegularExpression('#<video class="section-video"[^>]* muted loop playsinline preload="none"[^>]*data-video-loop#', $html);
// Das WebM steht zuerst: der Browser nimmt die erste Quelle, die er abspielen kann.
self::assertLessThan(strpos($html, 'src="'.$item['src'].'"'), strpos($html, 'src="'.$webm['src'].'"'));
self::assertStringContainsString('class="video-toggle"', $html);
self::assertStringContainsString('aria-label="Unser Betrieb"', $html);
self::assertMatchesRegularExpression('#<video class="video-media" controls playsinline preload="metadata" poster=#', $html);
self::assertStringContainsString('<script type="module" src="/assets/video.js?v=', $html);
self::assertStringContainsString("script-src 'self'", (string) $seite->headers->get('Content-Security-Policy'));
// Ein Spieler allein braucht kein Skript: die Seite bleibt bei script-src 'none'.
$spieler = DemoContent::starter('Startseite');
$spieler['schemaVersion'] = DocumentValidator::SCHEMA_VERSION;
$spieler['blocks'][] = ['id' => 'film', 'type' => 'video', 'props' => ['src' => $item['src'], 'mode' => 'player', 'title' => '']];
$version = $this->pages->findForEditor('page-1')['version'];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $spieler, $version), $version + 1);
$ohne = $this->request('/');
self::assertStringNotContainsString('video.js', $ohne->getContent());
self::assertStringContainsString("script-src 'none'", (string) $ohne->headers->get('Content-Security-Policy'));
// Fremde Adressen, zu altes Schema und unbekannte Betriebsarten weist das Dokument ab.
$pruefer = new DocumentValidator();
foreach ([
['video', ['src' => 'https://example.com/film.mp4'], DocumentValidator::SCHEMA_VERSION],
['video', ['src' => '/media/../secret.mp4'], DocumentValidator::SCHEMA_VERSION],
['video', ['src' => $item['src'], 'mode' => 'autoplay'], DocumentValidator::SCHEMA_VERSION],
['video', ['src' => $item['src']], 27],
] as [$type, $props, $schema]) {
$falsch = DemoContent::starter('Startseite');
$falsch['schemaVersion'] = $schema;
$falsch['blocks'][] = ['id' => 'film', 'type' => $type, 'props' => $props];
try {
$pruefer->validate($falsch);
self::fail('Abgewiesen werden muss: '.json_encode($props));
} catch (InvalidDocument) {
$this->addToAssertionCount(1);
}
}
}
public function testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer(): void public function testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer(): void
{ {
// Umut: „PDFs hochladen und als Button verlinken - und ein Inline-PDF-Viewer wie das // Umut: „PDFs hochladen und als Button verlinken - und ein Inline-PDF-Viewer wie das
+2
View File
@@ -24,6 +24,7 @@ use DoctrineMigrations\Version20260916000100;
use DoctrineMigrations\Version20260917000100; use DoctrineMigrations\Version20260917000100;
use DoctrineMigrations\Version20261009000100; use DoctrineMigrations\Version20261009000100;
use DoctrineMigrations\Version20261012000100; use DoctrineMigrations\Version20261012000100;
use DoctrineMigrations\Version20261104000100;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
/** /**
@@ -59,6 +60,7 @@ final class TestSchema
Version20260917000100::class, Version20260917000100::class,
Version20261009000100::class, Version20261009000100::class,
Version20261012000100::class, Version20261012000100::class,
Version20261104000100::class,
] as $migration) { ] as $migration) {
(new $migration($db, new NullLogger()))->up($schema); (new $migration($db, new NullLogger()))->up($schema);
} }