PWA: Foto-Galerie mit Zoom und Swipe [deploy]
All checks were successful
Deploy bericht / deploy (push) Successful in 1s

- Neue API: list_photos.php listet vorhandene Bilder
- Neue API: get_photo.php liefert Bilder per Token aus
- PWA zeigt Galerie mit allen vorhandenen Fotos
- Tippen auf Thumbnail öffnet Vollbild-Viewer
- Pinch-to-Zoom, Doppeltap, Swipe-Navigation
- Galerie aktualisiert sich nach Upload automatisch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-04-13 13:09:30 +02:00
parent 1512e4d706
commit a31e063e7a
3 changed files with 271 additions and 0 deletions

72
ajax/get_photo.php Normal file
View file

@ -0,0 +1,72 @@
<?php
/* Liefert ein Foto aus dem Upload-Ordner aus.
* Authentifizierung über Token in der URL.
*
* GET: token, file (Dateiname)
*/
if (!defined('NOLOGIN')) define('NOLOGIN', '1');
if (!defined('NOCSRFCHECK')) define('NOCSRFCHECK', '1');
if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1');
if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1');
if (!defined('NOREQUIREHTML')) define('NOREQUIREHTML', '1');
if (!defined('NOREQUIREAJAX')) define('NOREQUIREAJAX', '1');
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; $tmp2 = realpath(__FILE__); $i = strlen($tmp) - 1; $j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { $i--; $j--; }
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res) die("Include of main fails");
require_once __DIR__.'/../class/upload_token.class.php';
// Token validieren
$token = (string) ($_REQUEST['token'] ?? '');
$tok = BerichtUploadToken::fetchValid($db, $token);
if (!$tok) {
http_response_code(403);
die('Token ungültig');
}
// Dateiname validieren (keine Pfad-Traversal erlauben)
$filename = basename((string) ($_REQUEST['file'] ?? ''));
if (empty($filename)) {
http_response_code(400);
die('Dateiname fehlt');
}
// Upload-Ordner ermitteln
$upload_dir = $tok->getUploadDir();
if (!$upload_dir) {
http_response_code(404);
die('Ordner nicht gefunden');
}
$filepath = $upload_dir . '/' . $filename;
// Prüfen ob Datei existiert und im erlaubten Ordner liegt
$realpath = realpath($filepath);
$realdir = realpath($upload_dir);
if (!$realpath || !$realdir || strpos($realpath, $realdir) !== 0) {
http_response_code(404);
die('Datei nicht gefunden');
}
// Datei ausliefern
$mime = mime_content_type($realpath);
if (!$mime || strpos($mime, 'image') !== 0) {
$mime = 'application/octet-stream';
}
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($realpath));
header('Cache-Control: private, max-age=3600');
readfile($realpath);
exit;

67
ajax/list_photos.php Normal file
View file

@ -0,0 +1,67 @@
<?php
/* Listet vorhandene Fotos im Upload-Ordner des Objekts auf.
* Authentifizierung über Token in der URL.
*
* GET: token
* Response: JSON { success: true, photos: [{ filename, url, size, date }] }
*/
if (!defined('NOLOGIN')) define('NOLOGIN', '1');
if (!defined('NOCSRFCHECK')) define('NOCSRFCHECK', '1');
if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1');
if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1');
if (!defined('NOREQUIREHTML')) define('NOREQUIREHTML', '1');
if (!defined('NOREQUIREAJAX')) define('NOREQUIREAJAX', '1');
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; $tmp2 = realpath(__FILE__); $i = strlen($tmp) - 1; $j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { $i--; $j--; }
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res) die("Include of main fails");
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
require_once __DIR__.'/../class/upload_token.class.php';
header('Content-Type: application/json; charset=utf-8');
// Token validieren
$token = (string) ($_REQUEST['token'] ?? '');
$tok = BerichtUploadToken::fetchValid($db, $token);
if (!$tok) {
http_response_code(403);
echo json_encode(array('success' => false, 'error' => 'Token ungültig oder abgelaufen'));
exit;
}
// Upload-Ordner ermitteln
$upload_dir = $tok->getUploadDir();
if (!$upload_dir || !is_dir($upload_dir)) {
echo json_encode(array('success' => true, 'photos' => array()));
exit;
}
// Bilder auflisten
$files = dol_dir_list($upload_dir, 'files', 0, '\.(jpg|jpeg|png|gif)$', '', 'date', SORT_DESC);
$photos = array();
$base_url = dol_buildpath('/bericht/ajax/get_photo.php', 1);
foreach ($files as $f) {
$photos[] = array(
'filename' => $f['name'],
'url' => $base_url . '?token=' . urlencode($token) . '&file=' . urlencode($f['name']),
'size' => $f['size'],
'date' => dol_print_date($f['date'], '%Y-%m-%d %H:%M'),
);
}
echo json_encode(array(
'success' => true,
'photos' => $photos,
'count' => count($photos),
));

View file

@ -631,6 +631,52 @@ body {
.scanner-page-thumb { .scanner-page-thumb {
cursor: pointer; cursor: pointer;
} }
/* ========== Foto-Galerie (vorhandene Bilder) ========== */
.photo-gallery {
padding: 16px;
background: #222;
border-radius: 8px;
margin: 16px;
}
.photo-gallery h3 {
margin: 0 0 12px 0;
font-size: 14px;
color: #aaa;
font-weight: normal;
}
.photo-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 8px;
}
.photo-gallery-item {
aspect-ratio: 1;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
background: #333;
}
.photo-gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-gallery-item:active {
opacity: 0.7;
}
.photo-gallery-empty {
color: #666;
font-size: 13px;
text-align: center;
padding: 20px;
}
.photo-gallery-loading {
color: #888;
font-size: 13px;
text-align: center;
padding: 20px;
}
</style> </style>
</head> </head>
<body> <body>
@ -650,6 +696,14 @@ body {
<input type="file" id="gallery-input" class="hidden-input" accept="image/*" multiple> <input type="file" id="gallery-input" class="hidden-input" accept="image/*" multiple>
</div> </div>
<!-- Vorhandene Fotos -->
<div class="photo-gallery" id="photo-gallery">
<h3>📷 Vorhandene Fotos (<span id="photo-gallery-count">...</span>)</h3>
<div class="photo-gallery-grid" id="photo-gallery-grid">
<div class="photo-gallery-loading">Lade Fotos...</div>
</div>
</div>
<!-- Kamera-View Overlay --> <!-- Kamera-View Overlay -->
<div id="camera-view" class="camera-view hidden"> <div id="camera-view" class="camera-view hidden">
<video id="camera-stream" autoplay playsinline muted></video> <video id="camera-stream" autoplay playsinline muted></video>
@ -878,6 +932,11 @@ async function savePhoto() {
uploadedList.prepend(item); uploadedList.prepend(item);
toast('Foto gespeichert'); toast('Foto gespeichert');
// Galerie aktualisieren
if (typeof loadGalleryPhotos === 'function') {
setTimeout(loadGalleryPhotos, 300);
}
} else { } else {
toast('Fehler: ' + (data.error || 'unbekannt'), true); toast('Fehler: ' + (data.error || 'unbekannt'), true);
} }
@ -1112,6 +1171,11 @@ async function finishScanning() {
toast('Dokument erstellt: ' + scannedPages.length + ' Seiten'); toast('Dokument erstellt: ' + scannedPages.length + ' Seiten');
closeScannerView(); closeScannerView();
// Galerie aktualisieren
if (typeof loadGalleryPhotos === 'function') {
setTimeout(loadGalleryPhotos, 300);
}
} else { } else {
scannerUploading.classList.add('hidden'); scannerUploading.classList.add('hidden');
toast('Fehler: ' + (data.error || 'unbekannt'), true); toast('Fehler: ' + (data.error || 'unbekannt'), true);
@ -1150,6 +1214,11 @@ async function uploadFile(file) {
item.innerHTML = '<span class="check">✓</span><span class="name">' + escapeHtml(data.filename) + '</span>'; item.innerHTML = '<span class="check">✓</span><span class="name">' + escapeHtml(data.filename) + '</span>';
uploadedList.prepend(item); uploadedList.prepend(item);
toast('Hochgeladen: ' + fname); toast('Hochgeladen: ' + fname);
// Galerie aktualisieren
if (typeof loadGalleryPhotos === 'function') {
setTimeout(loadGalleryPhotos, 300);
}
} else { } else {
toast('Fehler: ' + (data.error || 'unbekannt'), true); toast('Fehler: ' + (data.error || 'unbekannt'), true);
} }
@ -1392,6 +1461,69 @@ scannerPagesStrip.addEventListener('click', (e) => {
pwaViewer.open(images, idx >= 0 ? idx : 0); pwaViewer.open(images, idx >= 0 ? idx : 0);
} }
}); });
// === FOTO-GALERIE (vorhandene Bilder) ===
const photoGalleryGrid = document.getElementById('photo-gallery-grid');
const photoGalleryCount = document.getElementById('photo-gallery-count');
let galleryPhotos = [];
async function loadGalleryPhotos() {
try {
const r = await fetch('ajax/list_photos.php?token=' + encodeURIComponent(TOKEN));
const data = await r.json();
if (data.success && data.photos) {
galleryPhotos = data.photos;
renderGallery();
} else {
photoGalleryGrid.innerHTML = '<div class="photo-gallery-empty">Fehler beim Laden</div>';
}
} catch (e) {
console.error('Galerie laden fehlgeschlagen:', e);
photoGalleryGrid.innerHTML = '<div class="photo-gallery-empty">Fehler beim Laden</div>';
}
}
function renderGallery() {
photoGalleryCount.textContent = galleryPhotos.length;
if (galleryPhotos.length === 0) {
photoGalleryGrid.innerHTML = '<div class="photo-gallery-empty">Noch keine Fotos vorhanden</div>';
return;
}
photoGalleryGrid.innerHTML = galleryPhotos.map((photo, idx) => `
<div class="photo-gallery-item" data-idx="${idx}">
<img src="${escapeHtml(photo.url)}" alt="${escapeHtml(photo.filename)}" loading="lazy">
</div>
`).join('');
}
// Galerie-Klick öffnet Viewer
photoGalleryGrid.addEventListener('click', (e) => {
const item = e.target.closest('.photo-gallery-item');
if (!item) return;
const idx = parseInt(item.dataset.idx, 10) || 0;
const images = galleryPhotos.map(p => ({ url: p.url, title: p.filename }));
if (images.length > 0) {
pwaViewer.open(images, idx);
}
});
// Nach Upload Galerie aktualisieren
const origUploadFile = uploadFile;
async function uploadFileWithRefresh(file) {
await origUploadFile(file);
// Galerie nach kurzer Verzögerung neu laden (Upload muss fertig sein)
setTimeout(loadGalleryPhotos, 500);
}
// Ersetze uploadFile-Referenzen - nein, besser direkt nach erfolgreichem Upload
// Wir fügen den Refresh in die savePhoto-Funktion ein
// Galerie beim Laden initialisieren
loadGalleryPhotos();
</script> </script>
</body> </body>