Foto-Backend: Upload-Thumbnails + Overlay-Lightbox

- Hochgeladene Fotos erscheinen als Thumbnail-Raster beim Slot (110px, GD-generiert
  mit EXIF-Orientierung, gecacht in uploads/buch-fotos/.thumbs)
- Klick öffnet Overlay in groß (Esc/Klick/✕ schließt); auch eingebaute Slot-Fotos klickbar
- Auslieferung nur über die Seite hinter der PIN (?img=/?thumb=); Ordner bleibt gesperrt
- Notizen werden als 📝-Zeile beim Slot angezeigt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 17:14:13 +02:00
parent a3d81b8983
commit f91f3f26de
+92 -3
View File
@@ -64,6 +64,52 @@ $MAX_BYTES = 40 * 1024 * 1024; // 40 MB je Datei
$MAX_FILES = 12; // je Absenden
$ALLOWED_EXT = ['jpg','jpeg','png','heic','heif','webp','tif','tiff'];
/* ---------- Bild-Auslieferung (nur mit PIN; der Ordner selbst bleibt gesperrt) ---------- */
if (isset($_GET['img']) || isset($_GET['thumb'])) {
$req = $_GET['img'] ?? $_GET['thumb'];
$sim = preg_replace('/[^a-z0-9-]/', '', dirname($req));
$file = basename($req);
$pfad = "{$UPLOAD_BASE}/{$sim}/{$file}";
if ($sim === '' || $file === '' || strpos($file, '..') !== false || !is_file($pfad)) {
http_response_code(404); exit('nicht gefunden');
}
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
$mime = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp',
'heic' => 'image/heic', 'heif' => 'image/heif', 'tif' => 'image/tiff', 'tiff' => 'image/tiff'][$ext] ?? 'application/octet-stream';
if (isset($_GET['thumb'])) {
$tdir = "{$UPLOAD_BASE}/.thumbs";
if (!is_dir($tdir)) mkdir($tdir, 0775, true);
$tpfad = "{$tdir}/{$sim}__{$file}.jpg";
if (!is_file($tpfad) || filemtime($tpfad) < filemtime($pfad)) {
$src = @imagecreatefromstring((string)@file_get_contents($pfad));
if ($src) {
// iPhone/Kamera-Fotos: EXIF-Orientierung berücksichtigen
if (in_array($ext, ['jpg', 'jpeg'], true) && function_exists('exif_read_data')) {
$exif = @exif_read_data($pfad);
$o = (int)($exif['Orientation'] ?? 1);
if ($o === 3) $src = imagerotate($src, 180, 0);
elseif ($o === 6) $src = imagerotate($src, -90, 0);
elseif ($o === 8) $src = imagerotate($src, 90, 0);
}
$w = imagesx($src); $h = imagesy($src);
$f = min(1, 340 / max($w, $h));
$tw = max(1, (int)round($w * $f)); $th = max(1, (int)round($h * $f));
$dst = imagecreatetruecolor($tw, $th);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $tw, $th, $w, $h);
imagejpeg($dst, $tpfad, 82);
imagedestroy($src); imagedestroy($dst);
}
}
if (is_file($tpfad)) { $pfad = $tpfad; $mime = 'image/jpeg'; }
// sonst (z. B. HEIC ohne GD-Support): Original ausliefern — Safari/iPad zeigt es nativ
}
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string)filesize($pfad));
header('Cache-Control: private, max-age=86400');
readfile($pfad);
exit;
}
/* ---------- Slots aus den Kapiteln parsen ---------- */
$ORDER = ['tourismustal','tourismusregion','kofferdetektiv','busfahrt','logistik',
'weltkueche','farmer','klima','fluss','energiemanager','heli',
@@ -105,6 +151,16 @@ function pendingUploads(string $base, string $sim, string $slug): array {
}
return $out;
}
function pendingNotes(string $base, string $sim, string $slug): array {
$dir = "{$base}/{$sim}";
if (!is_dir($dir)) return [];
$out = [];
foreach (glob($dir . '/' . $slug . '__*.note.txt') as $f) {
$t = trim((string)file_get_contents($f));
if ($t !== '') $out[] = $t;
}
return $out;
}
function safeName(string $s): string {
$s = preg_replace('/[^A-Za-z0-9._-]+/', '-', $s);
return trim(mb_substr($s, 0, 80), '-.') ?: 'foto';
@@ -192,7 +248,17 @@ foreach ($KAPITEL as $sim => $k) {
.badge.offen { background: #eeeadd; color: #6b6659; }
.beschr { line-height: 1.55; font-size: 14.5px; margin: 8px 0 10px; }
.thumb { max-width: 190px; border-radius: 8px; display: block; margin: 6px 0; }
.pend { font-size: 12.5px; color: #a06a12; margin: 4px 0; }
a.lightbox { cursor: zoom-in; }
.pgrid { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
.pgrid .pt img { width: 110px; height: 110px; object-fit: cover; border-radius: 8px; display: block;
border: 2px solid #e8c9a0; background: #eee; }
.pnote { font-size: 13px; color: #6b6659; font-style: italic; margin: 2px 0 6px; }
#ov { position: fixed; inset: 0; background: rgba(20,26,30,.88); display: flex; align-items: center;
justify-content: center; z-index: 99; cursor: zoom-out; }
#ov[hidden] { display: none; }
#ov img { max-width: 94vw; max-height: 92vh; border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,.5); }
#ovx { position: fixed; top: 12px; right: 18px; color: #fff; font-size: 30px; font-weight: 800; cursor: pointer;
text-shadow: 0 1px 6px rgba(0,0,0,.7); }
form.up { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; background: #f4f1e6; border-radius: 9px; padding: 10px 12px; }
form.up input[type=file] { font-size: 13px; max-width: 100%; }
form.up input[type=text] { flex: 1 1 220px; min-width: 180px; border: 1.5px solid #c9c2ae; border-radius: 8px; padding: 8px 10px; font-size: 14px; background: #fff; }
@@ -243,8 +309,17 @@ foreach ($KAPITEL as $sim => $k) {
<span class="badge <?= $status ?>"><?= $bild ? '✅ Foto eingebaut' : ($pend ? '📥 Upload wartet auf Einbau' : '📷 Foto gesucht') ?></span>
</div>
<p class="beschr"><?= htmlspecialchars($vorschlag) ?></p>
<?php if ($bild): ?><img class="thumb" src="<?= $bp ?>/<?= $bild ?>" alt="" loading="lazy"><?php endif; ?>
<?php foreach ($pend as $p): ?><div class="pend">📥 <?= htmlspecialchars($p['name']) ?> (<?= $p['kb'] ?> KB)</div><?php endforeach; ?>
<?php if ($bild): ?><a class="lightbox" href="<?= $bp ?>/<?= $bild ?>"><img class="thumb" src="<?= $bp ?>/<?= $bild ?>" alt="" loading="lazy"></a><?php endif; ?>
<?php if ($pend): ?>
<div class="pgrid">
<?php foreach ($pend as $p): ?>
<a class="pt lightbox" href="?img=<?= urlencode($sim . '/' . $p['name']) ?>" title="<?= htmlspecialchars($p['name']) ?> · <?= $p['kb'] ?> KB">
<img src="?thumb=<?= urlencode($sim . '/' . $p['name']) ?>" loading="lazy" alt="">
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php foreach (pendingNotes($UPLOAD_BASE, $sim, $slug) as $n): ?><div class="pnote">📝 <?= htmlspecialchars($n) ?></div><?php endforeach; ?>
<form class="up" method="post" enctype="multipart/form-data" action="?#<?= $sim ?>-<?= $slug ?>">
<input type="hidden" name="sim" value="<?= $sim ?>">
<input type="hidden" name="slug" value="<?= $slug ?>">
@@ -269,5 +344,19 @@ foreach ($KAPITEL as $sim => $k) {
</form>
</div>
</div>
<div id="ov" hidden><img id="ovimg" src="" alt=""><span id="ovx">✕</span></div>
<script>
(function () {
var ov = document.getElementById('ov'), ovimg = document.getElementById('ovimg');
function zu() { ov.hidden = true; ovimg.src = ''; document.body.style.overflow = ''; }
document.addEventListener('click', function (e) {
var a = e.target.closest('a.lightbox');
if (a) { e.preventDefault(); ovimg.src = a.href; ov.hidden = false; document.body.style.overflow = 'hidden'; return; }
if (!ov.hidden) zu();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !ov.hidden) zu(); });
})();
</script>
</body>
</html>