Skip to main content

📷 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.

bs5GalleryModal.jpg

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">&times;</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">
          &#8249;
        </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">
          &#8250;
        </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>