📷 Bildergalerie
bs5GalleryModal
- Diese einfache Galerie mit optionaler pThumb Unterstützung zeigt automatisch alle Bilder aus einem Ordner als Vorschau und lädt sie dank Lazy-Loading nur bei Bedarf.
- Beim Anklicken öffnet sich ein großes Modal mit Bildtitel, Navigation vor/zurück, Zoom, Tastatursteuerung und Wischgesten auf dem Smartphone.
- Bilder-Ordner können einfach über TVs integriert werden.
Snippet bs5GalleryModal
<?php
/**
* bs5GalleryModal
* V 2025-11-28
* mit pThumb Unterstützung (falls installiert)
*
* Properties:
* - dir (string) : Bilder-Verzeichnis
* - SORT (string) : 'asc' oder 'desc'
* - tpl (string) : Chunk für einzelnes Thumbnail (Standard: bs5GalleryModalTPL)
* - wrapperTpl : Chunk für Galerie-Wrapper (Standard: bs5GalleryModalWrapperTPL)
* - modalTpl : Chunk für Modal-HTML (Standard: bs5GalleryModalModalTPL)
* - jsTpl : Chunk mit JS (Standard: bs5GalleryModalJSTPL)
* - options (str) : pthumb-Options (Standard: w=180&h=180&zc=1&q=70)
*
* Wenn du die Standardnamen beibehältst, musst du diese nicht alle angeben.
* Beispiel:
* [[!bs5GalleryModal?
* &dir=`assets/modx/content/redaktion/[[*tvGalleryPfad]]`
* &SORT=`asc`
* &tpl=`bs5GalleryModalTPL`
* &options=`w=300&h=200&zc=1&q=80`
* ]]
*/
$thumbMax = 180; # Maximale Thumbnailgröße für die Anzeige im Galerie-Layout
$thumbOptions = $modx->getOption('options', $scriptProperties, 'w=' . $thumbMax . '&h=' . $thumbMax . '&zc=1&q=70');
$strSORT = $modx->getOption('SORT', $scriptProperties, 'asc');
$dir = trim($modx->getOption('dir', $scriptProperties, ''), '/');
$tpl = $modx->getOption('tpl', $scriptProperties, 'bs5GalleryModalTPL');
$wrapperTpl = $modx->getOption('wrapperTpl', $scriptProperties, 'bs5GalleryModalWrapperTPL');
$modalTpl = $modx->getOption('modalTpl', $scriptProperties, 'bs5GalleryModalModalTPL');
$jsTpl = $modx->getOption('jsTpl', $scriptProperties, 'bs5GalleryModalJSTPL');
if ($dir === '' || !is_dir($dir)) {
return 'Das Verzeichnis "' . $dir . '" existiert nicht!';
}
$handle = opendir($dir);
if ($handle === false) {
return '';
}
$files = [];
while (($file = readdir($handle)) !== false) {
if ($file === '.' || $file === '..') {
continue;
}
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
continue;
}
$fullPath = $dir . '/' . $file;
$sizeKB = @filesize($fullPath);
$sizeKB = $sizeKB ? ceil($sizeKB / 1024) : 0;
$mtime = @filemtime($fullPath);
$mtime = $mtime ?: 0;
$files[$file] = [
'file' => $file,
'sizeKB' => $sizeKB,
'mtime' => $mtime,
];
}
closedir($handle);
if (empty($files)) {
return '';
}
// Sortierung nach Dateinamen (natürlich, also 2 vor 10)
if ($strSORT === 'asc') {
ksort($files, SORT_NATURAL | SORT_FLAG_CASE);
} else {
krsort($files, SORT_NATURAL | SORT_FLAG_CASE);
}
// eindeutige ID für Modal
$uid = uniqid('gal_');
$items = [];
$index = 0;
foreach ($files as $data) {
$file = $data['file'];
$sizeKB = $data['sizeKB'];
$mtime = $data['mtime'];
$src = '/' . $dir . '/' . $file;
$altBase = pathinfo($file, PATHINFO_FILENAME); // Dateiname ohne Extension
$alt = htmlspecialchars($altBase, ENT_QUOTES, 'UTF-8');
$dateStr = $mtime ? date('Y-m-d', $mtime) : '';
// Thumbnail-URL über pthumb erzeugen (großes Bild bleibt $src)
$thumbUrl = $modx->runSnippet('pthumb', [
'input' => $src,
'options' => $thumbOptions,
]);
if (empty($thumbUrl)) {
$thumbUrl = $src; // Fallback, falls pthumb nicht verfügbar ist
}
$items[] = $modx->getChunk($tpl, [
'uid' => $uid,
'index' => $index,
'src' => $src,
'thumb' => $thumbUrl,
'alt' => $alt,
'size' => $sizeKB,
'date' => $dateStr,
'thumbMax' => $thumbMax,
]);
$index++;
}
$itemsHtml = implode("\n", $items);
// Wrapper-HTML (Galerie-Raster)
$output = $modx->getChunk($wrapperTpl, [
'uid' => $uid,
'items' => $itemsHtml,
]);
// Modal-HTML
$modalHtml = $modx->getChunk($modalTpl, [
'uid' => $uid,
]);
// JS einbinden
$js = $modx->getChunk($jsTpl, [
'uid' => $uid,
]);
if (!empty($js)) {
$modx->regClientScript($js, true);
}
return $output . "\n" . $modalHtml;
Chunk bs5GalleryModalTPL
<!-- Einzelnes Thumbnail-Item -->
<div class="col-6 col-md-4 col-lg-3">
<a href="#"
data-bs-toggle="modal"
data-bs-target="#[[+uid]]"
data-gallery="[[+uid]]"
data-index="[[+index]]"
data-img="[[+src]]"
data-caption="[[+alt]]"
data-size="[[+size]]"
data-date="[[+date]]"
class="gal-thumb d-block">
<img src="[[+thumb]]"
class="img-fluid rounded shadow-sm"
alt="[[+alt]]"
loading="lazy"
style="max-width:[[+thumbMax]]px;">
</a>
</div>
Chunk bs5GalleryModalWrapperTPL
<!-- Wrapper um alle Thumbnails -->
<div class="row g-1 bs5-gallery">
[[+items]]
</div>
Chunk bs5GalleryModalModalTPL
<!-- Modal mit Infobar + Zoom -->
<div class="modal fade bs5-gallery-modal" id="[[+uid]]" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white border-0">
<div class="modal-body p-2 text-center position-relative">
<button type="button"
class="btn btn-sm btn-light position-absolute top-0 end-0 m-2"
data-bs-dismiss="modal"
aria-label="Close">×</button>
<button type="button"
class="btn btn-outline-light btn-sm position-absolute top-50 start-0 translate-middle-y"
id="[[+uid]]_prev"
aria-label="Vorheriges Bild">
‹
</button>
<button type="button"
class="btn btn-outline-light btn-sm position-absolute top-50 end-0 translate-middle-y"
id="[[+uid]]_next"
aria-label="Nächstes Bild">
›
</button>
<img id="[[+uid]]_img"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
class="img-fluid d-block mx-auto"
alt=""
style="max-height:90vh; cursor:pointer;"
data-bs-dismiss="modal">
<div id="[[+uid]]_caption" class="gallery-infobar small mt-2"></div>
<div class="d-flex justify-content-center align-items-center gap-2 mt-2 flex-wrap">
<div class="btn-group btn-group-sm" role="group" aria-label="Zoom" id="[[+uid]]_zoombar">
<button type="button" class="btn btn-light" id="[[+uid]]_zoom_out">-</button>
<button type="button" class="btn btn-light" id="[[+uid]]_zoom_in">+</button>
</div>
</div>
</div>
</div>
</div>
</div>
Chunk bs5GalleryModalJSTPL
<script>
(function() {
var modalId = '[[+uid]]';
var modalEl = document.getElementById(modalId);
if (!modalEl) return;
var imgEl = document.getElementById(modalId + "_img");
var capEl = document.getElementById(modalId + "_caption");
var prevBtn = document.getElementById(modalId + "_prev");
var nextBtn = document.getElementById(modalId + "_next");
var zoomInBtn = document.getElementById(modalId + "_zoom_in");
var zoomOutBtn = document.getElementById(modalId + "_zoom_out");
var thumbs = Array.prototype.slice.call(
document.querySelectorAll("[data-gallery='[[+uid]]']")
);
if (!thumbs.length) return;
var currentIndex = 0;
var zoomLevel = 1;
var zoomMin = 0.5;
var zoomMax = 3;
var zoomStep = 0.25;
function applyZoom() {
if (!imgEl) return;
imgEl.style.transform = "scale(" + zoomLevel + ")";
}
function resetZoom() {
zoomLevel = 1;
applyZoom();
}
function buildInfoText(caption, size, date) {
var parts = [];
if (caption) parts.push(caption);
if (size) parts.push(size + " KB");
if (date) parts.push(date);
return parts.join(" · ");
}
function showImage(index) {
if (index < 0) index = thumbs.length - 1;
if (index >= thumbs.length) index = 0;
currentIndex = index;
var el = thumbs[currentIndex];
var imgSrc = el.getAttribute("data-img");
var caption = el.getAttribute("data-caption") || "";
var size = el.getAttribute("data-size") || "";
var date = el.getAttribute("data-date") || "";
if (imgEl) {
imgEl.src = imgSrc;
}
if (capEl) {
capEl.textContent = buildInfoText(caption, size, date);
}
resetZoom();
}
// Thumbnail-Klick → Bild anzeigen
thumbs.forEach(function(el) {
el.addEventListener("click", function(e) {
e.preventDefault();
var idx = parseInt(el.getAttribute("data-index"), 10);
if (isNaN(idx)) idx = 0;
showImage(idx);
});
});
// Buttons Vor/Zurück
if (prevBtn) {
prevBtn.addEventListener("click", function() {
showImage(currentIndex - 1);
});
}
if (nextBtn) {
nextBtn.addEventListener("click", function() {
showImage(currentIndex + 1);
});
}
// Zoom-Buttons
if (zoomInBtn) {
zoomInBtn.addEventListener("click", function() {
zoomLevel = Math.min(zoomLevel + zoomStep, zoomMax);
applyZoom();
});
}
if (zoomOutBtn) {
zoomOutBtn.addEventListener("click", function() {
zoomLevel = Math.max(zoomLevel - zoomStep, zoomMin);
applyZoom();
});
}
// Doppelklick auf Bild → Zoom-Toggle
if (imgEl) {
imgEl.addEventListener("dblclick", function(e) {
e.preventDefault();
if (zoomLevel === 1) {
zoomLevel = 2;
} else {
zoomLevel = 1;
}
applyZoom();
});
}
// Klick auf Bild: wenn nicht gezoomt → schließen
if (imgEl) {
imgEl.addEventListener("click", function() {
if (zoomLevel !== 1) return;
if (typeof bootstrap !== "undefined" && bootstrap.Modal) {
var modalInstance = bootstrap.Modal.getInstance(modalEl);
if (!modalInstance) {
modalInstance = new bootstrap.Modal(modalEl);
}
modalInstance.hide();
}
});
}
// Tastatur-Navigation (links/rechts)
modalEl.addEventListener("keydown", function(e) {
if (e.key === "ArrowLeft") {
e.preventDefault();
showImage(currentIndex - 1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
showImage(currentIndex + 1);
}
});
// Touch: Swipe + Pinch-Zoom
var touchStartX = null;
var touchStartY = null;
var touchThreshold = 50; // Mindestweg in px für Swipe
var pinchStartDist = null;
var pinchStartZoom = 1;
var isPinching = false;
function getTouchDist(t1, t2) {
var dx = t2.clientX - t1.clientX;
var dy = t2.clientY - t1.clientY;
return Math.sqrt(dx*dx + dy*dy);
}
function handleTouchStart(e) {
if (!e.touches || e.touches.length === 0) return;
if (e.touches.length === 1) {
// Swipe
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
} else if (e.touches.length === 2) {
// Pinch
isPinching = true;
pinchStartDist = getTouchDist(e.touches[0], e.touches[1]);
pinchStartZoom = zoomLevel;
}
}
function handleTouchMove(e) {
if (!e.touches || e.touches.length < 2) return;
if (!isPinching || pinchStartDist === null) return;
var newDist = getTouchDist(e.touches[0], e.touches[1]);
if (!newDist) return;
var factor = newDist / pinchStartDist;
zoomLevel = Math.max(zoomMin, Math.min(zoomMax, pinchStartZoom * factor));
applyZoom();
e.preventDefault();
}
function handleTouchEnd(e) {
if (isPinching) {
if (!e.touches || e.touches.length < 2) {
isPinching = false;
pinchStartDist = null;
}
return;
}
if (touchStartX === null || touchStartY === null) return;
if (!e.changedTouches || e.changedTouches.length === 0) return;
var touchEndX = e.changedTouches[0].clientX;
var touchEndY = e.changedTouches[0].clientY;
var deltaX = touchEndX - touchStartX;
var deltaY = touchEndY - touchStartY;
// Nur horizontale Swipes berücksichtigen (vertikales Scrollen ignorieren)
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > touchThreshold) {
if (deltaX < 0) {
// nach links wischen → nächstes Bild
showImage(currentIndex + 1);
} else {
// nach rechts wischen → vorheriges Bild
showImage(currentIndex - 1);
}
}
touchStartX = null;
touchStartY = null;
}
modalEl.addEventListener("touchstart", handleTouchStart, {passive: true});
modalEl.addEventListener("touchmove", handleTouchMove, {passive: false});
modalEl.addEventListener("touchend", handleTouchEnd, {passive: true});
})();
</script>
Bitte für bs5GalleryModal dieses CSS prüfen:
Siehe auch im Buch "MODX 3 - Installation & Einrichtung", die Seite "Bootstrap 5" unter tmpBS_meinStyle. Wenn ihr das erledigt habt, sollte das CSS in Ordnung sein.
/* bs5GalleryModal – Bild, Infobar, Buttons */
.bs5-gallery-modal .modal-body {
position: relative;
padding: 0.5rem 1rem 2.5rem; /* etwas weniger links/rechts, Platz unten für Zoom */
overflow: auto;
}
/* großes Bild */
.bs5-gallery-modal img {
max-height: 75vh; /* nutzt den Bildschirm gut aus */
margin-bottom: 1rem;
transform-origin: center center;
transition: transform 0.2s ease;
position: relative;
z-index: 1;
}
/* Infobar (Name · Größe · Datum) */
.bs5-gallery-modal .gallery-infobar {
color: #f8f9fa;
opacity: 0.9;
margin-bottom: 0.5rem;
}
/* Buttons im Modal über das Bild legen */
.bs5-gallery-modal .modal-body .btn {
z-index: 2;
}
/* Pfeil-Buttons als runde „Bubbles“ */
.bs5-gallery-modal .btn-outline-light {
border-radius: 50%;
width: 2.2rem;
height: 2.2rem;
padding: 0;
line-height: 2.2rem;
}
/* Galerie-Dialog: nicht zu breit */
.bs5-gallery-modal .modal-dialog {
max-width: 900px; /* ggf. 800–1000px anpassen */
margin: 1.75rem auto;
}
Optional 1
Snippet bs5GalleryTeaserImg
Holt ein einzelnes Bild aus einem Galerie-Ordner
/**
* bs5GalleryTeaserImg V2025-11-28
* mit pThumb Unterstützung (falls installiert)
* Holt ein einzelnes Bild aus einem Galerie-Ordner (ähnliche Logik wie bs5GalleryModal)
*
* Properties:
* - dir (string) : Basisordner der Galerie (z.B. assets/modx/content/redaktion/brauchtum/klappern/1993)
* - index (int) : Bild-Nummer (1 = erstes Bild, 2 = zweites Bild, ...)
* wenn leer oder <1 -> kein Output (kein Bild)
* - id (int) : Ressource-ID für den Link
* - alt (string) : Alt-Text für das Bild
* - SORT (string) : 'asc' oder 'desc' (Standard: asc), wie bs5GalleryModal
* - tpl (string) : Chunk für die HTML-Ausgabe (Standard: bs5GalleryTeaserImgTPL)
* - options (str) : pthumb-Options (Standard: w=300&h=200&zc=1&q=70)
*
* Beispiel:
* [[!bs5GalleryTeaserImg?
* &dir=`assets/modx/content/redaktion/[[+tvGalleryPfad]]`
* &index=`[[+tvStartseiteBild]]`
* &id=`[[+id]]`
* &alt=`[[+pagetitle]]`
* &tpl=`bs5GalleryTeaserImgTPL`
* &options=`h=300&q=70`
* ]]
*/
$dir = trim($modx->getOption('dir', $scriptProperties, ''), '/');
$index = (int)$modx->getOption('index', $scriptProperties, 0);
$id = (int)$modx->getOption('id', $scriptProperties, 0);
$alt = $modx->getOption('alt', $scriptProperties, '');
$strSORT = $modx->getOption('SORT', $scriptProperties, 'asc');
$tpl = $modx->getOption('tpl', $scriptProperties, 'bs5GalleryTeaserImgTPL');
$thumbOptions = $modx->getOption('options', $scriptProperties, 'w=300&h=200&zc=1&q=70');
// wenn kein Ordner oder kein Index: kein Bild
if ($dir === '' || $index < 1) {
return '';
}
// wie bs5GalleryModal: relativer Pfad, direkt prüfen
if (!is_dir($dir)) {
// Ordner existiert nicht -> still schweigen
return '';
}
$handle = opendir($dir);
if ($handle === false) {
return '';
}
$files = [];
while (($file = readdir($handle)) !== false) {
if ($file === '.' || $file === '..') {
continue;
}
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
continue;
}
$files[$file] = $file;
}
closedir($handle);
// nichts gefunden
if (empty($files)) {
return '';
}
// Sortierung wie in bs5GalleryModal
if ($strSORT === 'desc') {
krsort($files, SORT_NATURAL | SORT_FLAG_CASE);
} else {
ksort($files, SORT_NATURAL | SORT_FLAG_CASE);
}
// Index prüfen (1-basiert)
$filesList = array_values($files);
$idx = $index - 1;
if (!isset($filesList[$idx])) {
// angeforderter Index existiert nicht -> kein Bild
return '';
}
$file = $filesList[$idx];
// Web-Pfad (mit führendem Slash)
$src = '/' . $dir . '/' . $file;
// Alt-Text
if ($alt === '' || $alt === null) {
$altBase = pathinfo($file, PATHINFO_FILENAME);
$alt = $altBase;
}
$altEsc = htmlspecialchars($alt, ENT_QUOTES, 'UTF-8');
// Thumbnail via pthumb (optional, falls installiert)
$srcThumb = $modx->runSnippet('pthumb', [
'input' => $src,
'options' => $thumbOptions,
]);
// wenn pthumb nicht existiert → Original verwenden
if (empty($srcThumb)) {
$srcThumb = $src;
}
// Link zur Ressource (falls ID gesetzt)
$link = $id ? $modx->makeUrl($id) : '';
// Ausgabe per Chunk
return $modx->getChunk($tpl, [
'src' => $src,
'thumb' => $srcThumb,
'alt' => $altEsc,
'link' => $link,
]);
Chunk bs5GalleryTeaserImgTPL
<div class="col-md-3 d-none d-md-block">
[[+link:notempty=`<a href="[[+link]]">`]]
<img src="[[+thumb]]"
class="img-fluid rounded-start"
alt="[[+alt]]">
[[+link:notempty=`</a>`]]
</div>
Optional 2
Snippet getStartseiteBild
Holt ein einzelnes Bild aus einer Ressource
<?php
/**
* getStartseiteBild V2025-11-28
* mit pThumb Unterstützung (falls installiert)
* Holt ein einzelnes Bild aus einer Ressource (ähnliche Logik wie bs5GalleryModal)
*
* Properties:
* - index (int) : Bild-Nummer (1 = erstes Bild, 2 = zweites Bild, ...)
* - id (int) : Ressource-ID zum Laden des Contents und als Linkziel
* - alt (string) : Alt-Text für das Bild
* - tpl (string) : Chunk für die HTML-Ausgabe (Standard: getStartseiteBildTPL)
* - options (str) : pthumb-Options (Standard: w=300&h=200&zc=1&q=70)
*
* Beispiel-Aufruf:
* [[!getStartseiteBild?
* &index=`[[+tvStartseiteBild]]`
* &id=`[[+id]]`
* &alt=`[[+pagetitle]]`
* &tpl=`getStartseiteBildTPL`
* &options=`w=300&q=70`
* ]]
*/
$index = (int)$modx->getOption('index', $scriptProperties, 0);
$id = (int)$modx->getOption('id', $scriptProperties, 0);
$alt = $modx->getOption('alt', $scriptProperties, '');
$tpl = $modx->getOption('tpl', $scriptProperties, 'getStartseiteBildTPL');
$thumbOptions = $modx->getOption('options', $scriptProperties, 'w=300&h=200&zc=1');
if ($index < 1 || $id < 1) {
return '';
}
// passende Resource-Klasse für MODX 2 / 3
$class = class_exists('MODX\\Revolution\\modResource')
? 'MODX\\Revolution\\modResource'
: 'modResource';
/** @var xPDOObject|null $res */
$res = $modx->getObject($class, $id);
if (!$res) {
return '';
}
$content = $res->get('content');
if (empty($content)) {
return '';
}
// alle <img>-SRCs aus dem Content holen
if (!preg_match_all('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $content, $matches)) {
return '';
}
$images = $matches[1];
$idx = $index - 1;
if (!isset($images[$idx])) {
return '';
}
$src = $images[$idx];
// Alt-Text fallback
if ($alt === '' || $alt === null) {
$alt = pathinfo($src, PATHINFO_FILENAME);
}
$altEsc = htmlspecialchars($alt, ENT_QUOTES, 'UTF-8');
// Thumbnail via pthumb (optional, falls installiert)
$srcThumb = $modx->runSnippet('pthumb', [
'input' => $src,
'options' => $thumbOptions,
]);
// Fallback auf Original
if (empty($srcThumb)) {
$srcThumb = $src;
}
// Link zur Ressource
$link = $modx->makeUrl($id);
// Ausgabe per Chunk
return $modx->getChunk($tpl, [
'src' => $src,
'thumb' => $srcThumb,
'alt' => $altEsc,
'link' => $link,
]);
Chunk getStartseiteBildTPL
<div class="col-md-3 d-none d-md-block">
[[+link:notempty=`<a href="[[+link]]">`]]
<img src="[[+thumb]]"
class="img-fluid rounded-start"
alt="[[+alt]]">
[[+link:notempty=`</a>`]]
</div>
