commit 47b5199c54d5c68120acd56ad9001cda28d73c25 Author: Jürgen Mummert Date: Wed Mar 4 15:44:07 2026 +0100 feat: add Contao 5.7 pinboard bundle diff --git a/README.md b/README.md new file mode 100644 index 0000000..0633ade --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Contao Pinboard Bundle + +Contao 5.7 Bundle für eine Pinwand mit frei angeordneten Pinnwandeinträgen. + +## Features + +- Backend-Entität `tl_pinnwand` unter **Inhalte** +- Felder: Überschrift, Text (max. 3000), Link, Bild, dateAdded, dateModified, published, hervorgehoben +- Frontend-Modul `pinnwand` (Twig) +- Kork-Hintergrund + zufällige, überlappende Notizzettel +- Drag-&-Drop als visueller Effekt (ohne Persistenz) +- Hervorgehobene Einträge immer im Vordergrund + +## Installation (Bundle im Projekt einbinden) + +1. Bundle per Composer einbinden. +2. Cache leeren. +3. Datenbank aktualisieren. +4. Assets installieren, damit CSS/JS unter `bundles/contaopinboard/` verfügbar sind. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..80e8ceb --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "eiswurm/contao-pinboard-bundle", + "description": "Pinboard bundle for Contao 5.7", + "type": "contao-bundle", + "license": "proprietary", + "require": { + "php": "^8.4", + "contao/core-bundle": "^5.7" + }, + "autoload": { + "psr-4": { + "Eiswurm\\ContaoPinboardBundle\\": "src/" + } + }, + "extra": { + "contao-manager-plugin": "Eiswurm\\ContaoPinboardBundle\\ContaoManager\\Plugin" + } +} diff --git a/contao/config/config.php b/contao/config/config.php new file mode 100644 index 0000000..e6f2955 --- /dev/null +++ b/contao/config/config.php @@ -0,0 +1,11 @@ + ['tl_pinnwand'], +]; + +$GLOBALS['TL_MODELS']['tl_pinnwand'] = PinboardModel::class; diff --git a/contao/config/services.yaml b/contao/config/services.yaml new file mode 100644 index 0000000..1093f48 --- /dev/null +++ b/contao/config/services.yaml @@ -0,0 +1,22 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + Eiswurm\ContaoPinboardBundle\: + resource: '../../src/*' + exclude: + - '../../src/ContaoManager/' + - '../../src/ContaoPinboardBundle.php' + + Eiswurm\ContaoPinboardBundle\Controller\FrontendModule\PinboardController: + tags: + - + name: contao.frontend_module + category: application + type: pinnwand + template: frontend_module/pinnwand + + Eiswurm\ContaoPinboardBundle\EventListener\DataContainer\PinboardTimestampListener: + tags: + - { name: contao.callback, table: tl_pinnwand, target: config.onsubmit } diff --git a/contao/dca/tl_pinnwand.php b/contao/dca/tl_pinnwand.php new file mode 100644 index 0000000..28b3e7f --- /dev/null +++ b/contao/dca/tl_pinnwand.php @@ -0,0 +1,124 @@ + [ + 'dataContainer' => Contao\DC_Table::class, + 'enableVersioning' => true, + 'sql' => [ + 'keys' => [ + 'id' => 'primary', + 'published' => 'index', + 'hervorgehoben' => 'index', + ], + ], + ], + 'list' => [ + 'sorting' => [ + 'mode' => 1, + 'fields' => ['hervorgehoben DESC', 'dateAdded DESC'], + 'flag' => 6, + 'panelLayout' => 'filter;sort,search,limit', + ], + 'label' => [ + 'fields' => ['ueberschrift', 'dateAdded'], + 'format' => '%s [%s]', + ], + 'global_operations' => [ + 'all' => [ + 'href' => 'act=select', + 'class' => 'header_edit_all', + 'attributes' => 'onclick="Backend.getScrollOffset()" accesskey="e"', + ], + ], + 'operations' => [ + 'edit' => [ + 'href' => 'act=edit', + 'icon' => 'edit.svg', + ], + 'copy' => [ + 'href' => 'act=copy', + 'icon' => 'copy.svg', + ], + 'delete' => [ + 'href' => 'act=delete', + 'icon' => 'delete.svg', + 'attributes' => 'onclick="if(!confirm(\'' . ($GLOBALS['TL_LANG']['MSC']['deleteConfirm'] ?? 'Möchten Sie den Eintrag wirklich löschen?') . '\'))return false;Backend.getScrollOffset()"', + ], + 'show' => [ + 'href' => 'act=show', + 'icon' => 'show.svg', + ], + ], + ], + 'palettes' => [ + 'default' => '{title_legend},ueberschrift,text,link,bild;{meta_legend},dateAdded,dateModified;{publish_legend},published,hervorgehoben', + ], + 'fields' => [ + 'id' => [ + 'sql' => 'int(10) unsigned NOT NULL auto_increment', + ], + 'tstamp' => [ + 'sql' => 'int(10) unsigned NOT NULL default 0', + ], + 'ueberschrift' => [ + 'exclude' => true, + 'search' => true, + 'sorting' => true, + 'inputType' => 'text', + 'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => "varchar(255) NOT NULL default ''", + ], + 'text' => [ + 'exclude' => true, + 'search' => true, + 'inputType' => 'textarea', + 'eval' => ['mandatory' => true, 'maxlength' => 3000, 'rte' => 'tinyMCE', 'allowHtml' => true, 'tl_class' => 'clr'], + 'sql' => 'text NULL', + ], + 'link' => [ + 'exclude' => true, + 'search' => true, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'url', 'decodeEntities' => true, 'maxlength' => 2048, 'dcaPicker' => true, 'tl_class' => 'w50'], + 'sql' => "varchar(2048) NOT NULL default ''", + ], + 'bild' => [ + 'exclude' => true, + 'inputType' => 'fileTree', + 'eval' => ['filesOnly' => true, 'fieldType' => 'radio', 'extensions' => Contao\Config::get('validImageTypes'), 'mandatory' => false, 'tl_class' => 'w50 clr'], + 'sql' => 'binary(16) NULL', + ], + 'dateAdded' => [ + 'sorting' => true, + 'flag' => 6, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'datim', 'datepicker' => true, 'mandatory' => true, 'tl_class' => 'w50 wizard'], + 'sql' => 'int(10) unsigned NOT NULL default 0', + ], + 'dateModified' => [ + 'sorting' => true, + 'flag' => 6, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'datim', 'readonly' => true, 'disabled' => true, 'tl_class' => 'w50'], + 'sql' => 'int(10) unsigned NOT NULL default 0', + ], + 'published' => [ + 'exclude' => true, + 'filter' => true, + 'toggle' => true, + 'inputType' => 'checkbox', + 'eval' => ['doNotCopy' => true, 'tl_class' => 'w50 m12'], + 'sql' => "char(1) NOT NULL default ''", + ], + 'hervorgehoben' => [ + 'exclude' => true, + 'filter' => true, + 'toggle' => true, + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50 m12'], + 'sql' => "char(1) NOT NULL default ''", + ], + ], +]; diff --git a/contao/languages/de/modules.php b/contao/languages/de/modules.php new file mode 100644 index 0000000..64b0bde --- /dev/null +++ b/contao/languages/de/modules.php @@ -0,0 +1,6 @@ +setLoadAfter([ContaoCoreBundle::class]), + ]; + } +} diff --git a/src/ContaoPinboardBundle.php b/src/ContaoPinboardBundle.php new file mode 100644 index 0000000..13a0697 --- /dev/null +++ b/src/ContaoPinboardBundle.php @@ -0,0 +1,11 @@ + 'hervorgehoben DESC, dateAdded DESC, id DESC'] + ); + + $notes = []; + + if (null !== $collection) { + foreach ($collection as $entry) { + $imagePath = null; + + if ($entry->bild) { + $fileModel = FilesModel::findByUuid($entry->bild); + $imagePath = $fileModel?->path; + } + + $notes[] = [ + 'id' => (int) $entry->id, + 'headline' => (string) $entry->ueberschrift, + 'text' => (string) $entry->text, + 'link' => (string) $entry->link, + 'dateAdded' => (int) $entry->dateAdded, + 'dateModified' => (int) $entry->dateModified, + 'imagePath' => $imagePath, + 'highlighted' => '1' === $entry->hervorgehoben, + ]; + } + } + + return $this->render('@Contao/frontend_module/pinnwand.html.twig', [ + 'entries' => $notes, + 'module' => $model, + ]); + } +} diff --git a/src/EventListener/DataContainer/PinboardTimestampListener.php b/src/EventListener/DataContainer/PinboardTimestampListener.php new file mode 100644 index 0000000..355fa8a --- /dev/null +++ b/src/EventListener/DataContainer/PinboardTimestampListener.php @@ -0,0 +1,35 @@ +id) { + return; + } + + $timestamp = time(); + + $record = Database::getInstance() + ->prepare('SELECT dateAdded FROM tl_pinnwand WHERE id = ?') + ->limit(1) + ->execute($dataContainer->id); + + if (!$record->numRows) { + return; + } + + $dateAdded = (int) $record->dateAdded; + + Database::getInstance() + ->prepare('UPDATE tl_pinnwand SET dateAdded = ?, dateModified = ?, tstamp = ? WHERE id = ?') + ->execute($dateAdded > 0 ? $dateAdded : $timestamp, $timestamp, $timestamp, $dataContainer->id); + } +} diff --git a/src/Model/PinboardModel.php b/src/Model/PinboardModel.php new file mode 100644 index 0000000..7c2542d --- /dev/null +++ b/src/Model/PinboardModel.php @@ -0,0 +1,12 @@ + { + const board = document.querySelector('[data-pinboard-surface]'); + + if (!board) { + return; + } + + const notes = Array.from(board.querySelectorAll('[data-pin-note]')); + + if (!notes.length) { + return; + } + + let zCounter = 200; + + const randomBetween = (min, max) => Math.random() * (max - min) + min; + + const placeNotes = () => { + const boardRect = board.getBoundingClientRect(); + const maxXBase = Math.max(24, boardRect.width - 300); + const maxYBase = Math.max(24, boardRect.height - 220); + + notes.forEach((note, index) => { + const maxX = Math.max(16, maxXBase - note.offsetWidth * 0.2); + const maxY = Math.max(16, maxYBase - note.offsetHeight * 0.2); + + const x = randomBetween(12, maxX); + const y = randomBetween(12, maxY); + const rotation = randomBetween(-7, 7); + const highlighted = note.dataset.highlighted === '1'; + const level = highlighted ? 1000 + index : 100 + index; + + note.dataset.baseRotation = String(rotation); + note.dataset.x = String(x); + note.dataset.y = String(y); + note.style.zIndex = String(level); + note.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`; + }); + }; + + const clampToBoard = (note, x, y) => { + const boardRect = board.getBoundingClientRect(); + const noteRect = note.getBoundingClientRect(); + const maxX = Math.max(0, boardRect.width - noteRect.width); + const maxY = Math.max(0, boardRect.height - noteRect.height); + + return { + x: Math.min(Math.max(0, x), maxX), + y: Math.min(Math.max(0, y), maxY), + }; + }; + + const enableDrag = (note) => { + let dragging = false; + let pointerId = null; + let offsetX = 0; + let offsetY = 0; + + note.addEventListener('pointerdown', (event) => { + dragging = true; + pointerId = event.pointerId; + note.setPointerCapture(pointerId); + note.classList.add('is-dragging'); + note.style.zIndex = String(++zCounter + 2000); + + const startX = Number.parseFloat(note.dataset.x ?? '0'); + const startY = Number.parseFloat(note.dataset.y ?? '0'); + offsetX = event.clientX - startX; + offsetY = event.clientY - startY; + }); + + note.addEventListener('pointermove', (event) => { + if (!dragging || event.pointerId !== pointerId) { + return; + } + + const baseRotation = Number.parseFloat(note.dataset.baseRotation ?? '0'); + const nextX = event.clientX - offsetX; + const nextY = event.clientY - offsetY; + const clamped = clampToBoard(note, nextX, nextY); + + note.dataset.x = String(clamped.x); + note.dataset.y = String(clamped.y); + note.style.transform = `translate(${clamped.x}px, ${clamped.y}px) rotate(${baseRotation}deg)`; + }); + + const releaseDrag = (event) => { + if (!dragging || event.pointerId !== pointerId) { + return; + } + + dragging = false; + note.classList.remove('is-dragging'); + note.releasePointerCapture(pointerId); + pointerId = null; + }; + + note.addEventListener('pointerup', releaseDrag); + note.addEventListener('pointercancel', releaseDrag); + }; + + const adjustBoardHeight = () => { + let requiredHeight = 620; + + notes.forEach((note) => { + const y = Number.parseFloat(note.dataset.y ?? '0'); + requiredHeight = Math.max(requiredHeight, y + note.offsetHeight + 36); + }); + + board.style.minHeight = `${Math.ceil(requiredHeight)}px`; + }; + + placeNotes(); + notes.forEach(enableDrag); + adjustBoardHeight(); + + window.addEventListener('resize', () => { + placeNotes(); + adjustBoardHeight(); + }); +})(); diff --git a/templates/frontend_module/pinnwand.html.twig b/templates/frontend_module/pinnwand.html.twig new file mode 100644 index 0000000..9b6fb26 --- /dev/null +++ b/templates/frontend_module/pinnwand.html.twig @@ -0,0 +1,38 @@ +{% extends '@Contao/frontend_module/_base.html.twig' %} + +{% block content %} + + +
+
+ {% for entry in entries %} +
+ {% if entry.imagePath %} +
+ +
+ {% endif %} + +

{{ entry.headline }}

+
{{ entry.text|raw }}
+ + {% if entry.link %} + + {% endif %} +
+ {% else %} +

Keine veröffentlichten Pinnwandeinträge vorhanden.

+ {% endfor %} +
+
+ + +{% endblock %}