bericht/mobile_upload.php
Eduard Wisch 0c6a262fe4
All checks were successful
Deploy bericht / deploy (push) Successful in 1s
[deploy] PWA Cache-Control Header hinzugefügt
Verhindert dass Browser die mobile_upload.php cached
- Cache-Control: no-cache, no-store, must-revalidate
- Pragma: no-cache
- Expires: 0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-13 13:15:33 +02:00

1535 lines
45 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/* Mobile-Upload-Page für Fotos zu einem Dolibarr-Objekt (Auftrag/Rechnung/Angebot).
* KEIN Dolibarr-Login nötig — Authentifizierung über Token in der URL.
* Fotos landen direkt im Dolibarr-Standard-Ordner des Objekts (z.B. commande/{ref}/).
*
* GET: token
* POST: token, file (multipart)
*/
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';
$token = (string) ($_REQUEST['token'] ?? '');
$tok = BerichtUploadToken::fetchValid($db, $token);
// Cache-Control: PWA-Seite NICHT cachen (immer frisch vom Server)
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
// POST = Datei-Upload
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_FILES['file']['tmp_name'])) {
header('Content-Type: application/json; charset=utf-8');
if (!$tok) {
http_response_code(403);
echo json_encode(array('success' => false, 'error' => 'Token ungültig oder abgelaufen'));
exit;
}
// Upload-Ziel über Token ermitteln (Dolibarr-Standard-Ordner)
$upload_dir = $tok->getUploadDir();
if (!$upload_dir) {
http_response_code(404);
echo json_encode(array('success' => false, 'error' => 'Objekt nicht gefunden'));
exit;
}
$orig = dol_sanitizeFileName($_FILES['file']['name']);
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
$allowed = array('jpg', 'jpeg', 'png');
if (!in_array($ext, $allowed)) {
echo json_encode(array('success' => false, 'error' => 'Nur JPG/PNG erlaubt'));
exit;
}
if (!is_dir($upload_dir)) dol_mkdir($upload_dir);
$filename = 'foto_'.dol_print_date(dol_now(), '%Y%m%d_%H%M%S').'_'.uniqid().'.'.$ext;
$target = $upload_dir.'/'.$filename;
if (!move_uploaded_file($_FILES['file']['tmp_name'], $target)) {
echo json_encode(array('success' => false, 'error' => 'Upload fehlgeschlagen'));
exit;
}
$tok->incrementCount();
echo json_encode(array('success' => true, 'filename' => $filename));
exit;
}
// GET = Mobile-Upload-Seite anzeigen
if (!$tok) {
http_response_code(403);
?>
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Foto Upload — Token ungültig</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background:#1a1a1f; color:#fff; padding: 40px 20px; text-align:center; }
h1 { color: #d9534f; }
</style>
</head><body>
<h1>Token ungültig</h1>
<p>Dieser Upload-Link ist abgelaufen oder ungültig.</p>
<p>Bitte im Editor einen neuen QR-Code generieren.</p>
</body></html>
<?php
exit;
}
// Parent-Objekt laden für Anzeige
$parent = $tok->fetchParentObject();
$parent_ref = $parent ? $parent->ref : '???';
$valid_min = max(1, round(($tok->expires_at - dol_now()) / 60));
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#1a1a1f">
<title>Foto Upload — <?php print htmlspecialchars($parent_ref); ?></title>
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #1a1a1f;
color: #f0f0f0;
margin: 0;
padding: 16px;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
.header {
text-align: center;
padding: 16px 0 24px;
border-bottom: 1px solid #333;
}
.header h1 { margin: 0 0 4px; font-size: 18px; color: #f0f0f0; }
.header .ref { color: #7aa2f7; font-size: 14px; }
.header .info { font-size: 11px; opacity: 0.6; margin-top: 8px; }
.action-buttons {
display: flex;
flex-direction: column;
gap: 12px;
margin: 24px 0;
}
.btn {
display: block;
width: 100%;
padding: 18px 24px;
font-size: 17px;
font-weight: 600;
border-radius: 12px;
border: none;
cursor: pointer;
background: #337ab7;
color: #fff;
text-align: center;
-webkit-appearance: none;
transition: transform 0.1s, background 0.15s;
}
.btn:active { transform: scale(0.97); background: #2868a0; }
.btn-secondary {
background: #2a2a30;
border: 1px solid #555;
}
.btn-secondary:active { background: #3a3a40; }
.hidden-input { display: none; }
.uploaded-list {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #333;
}
.uploaded-list h2 { font-size: 14px; opacity: 0.7; margin: 0 0 12px; text-transform: uppercase; }
.uploaded-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid #2a2a30;
font-size: 13px;
}
.uploaded-item .check { color: #5cb85c; }
.uploaded-item .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.uploading {
padding: 12px;
background: #2a2a30;
border-radius: 6px;
margin: 12px 0;
text-align: center;
color: #7aa2f7;
}
.toast {
position: fixed;
top: 16px;
left: 16px;
right: 16px;
background: #5cb85c;
color: #fff;
padding: 14px 16px;
border-radius: 8px;
text-align: center;
z-index: 2000;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
animation: slideDown 0.2s;
}
.toast.error { background: #d9534f; }
@keyframes slideDown { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; } }
/* Kamera-View Styles */
.camera-view {
position: fixed;
inset: 0;
background: #000;
z-index: 1000;
display: flex;
flex-direction: column;
}
.camera-view.hidden { display: none; }
#camera-stream {
flex: 1;
width: 100%;
height: 100%;
object-fit: cover;
}
#camera-canvas {
display: none;
}
.camera-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 24px 20px 40px;
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(transparent, rgba(0,0,0,0.7));
}
.camera-btn-close,
.camera-btn-switch {
width: 50px;
height: 50px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.2);
color: #fff;
font-size: 22px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.camera-btn-close:active,
.camera-btn-switch:active {
background: rgba(255,255,255,0.4);
}
.camera-shutter {
width: 72px;
height: 72px;
border-radius: 50%;
border: 4px solid #fff;
background: rgba(255,255,255,0.3);
cursor: pointer;
position: relative;
}
.camera-shutter::after {
content: '';
position: absolute;
inset: 6px;
border-radius: 50%;
background: #fff;
transition: transform 0.1s;
}
.camera-shutter:active::after {
transform: scale(0.9);
}
.camera-counter {
position: absolute;
top: 16px;
right: 16px;
background: rgba(0,0,0,0.5);
color: #5cb85c;
padding: 8px 14px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
}
/* Foto-Vorschau */
.photo-preview {
position: absolute;
inset: 0;
background: #000;
display: flex;
flex-direction: column;
}
.photo-preview.hidden { display: none; }
.photo-preview img {
flex: 1;
width: 100%;
height: 100%;
object-fit: contain;
}
.preview-buttons {
display: flex;
gap: 12px;
padding: 20px;
background: rgba(0,0,0,0.8);
}
.btn-discard,
.btn-save {
flex: 1;
padding: 16px;
font-size: 16px;
font-weight: 600;
border-radius: 10px;
border: none;
cursor: pointer;
}
.btn-discard {
background: #444;
color: #fff;
}
.btn-discard:active { background: #555; }
.btn-save {
background: #5cb85c;
color: #fff;
}
.btn-save:active { background: #4cae4c; }
/* Dokumenten-Scanner Styles */
.scanner-view {
position: fixed;
inset: 0;
background: #1a1a1f;
z-index: 1000;
display: flex;
flex-direction: column;
}
.scanner-view.hidden { display: none; }
.scanner-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #222;
border-bottom: 1px solid #333;
}
.scanner-header h2 {
margin: 0;
font-size: 16px;
color: #5cb85c;
}
.scanner-header .page-count {
font-size: 14px;
color: #888;
}
.scanner-camera-area {
flex: 1;
position: relative;
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
.scanner-camera-area video {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Seiten-Vorschau-Leiste am unteren Rand */
.scanner-pages-strip {
background: #222;
padding: 8px;
display: flex;
gap: 8px;
overflow-x: auto;
min-height: 80px;
align-items: center;
}
.scanner-page-thumb {
width: 60px;
height: 80px;
border-radius: 4px;
object-fit: cover;
border: 2px solid #444;
flex-shrink: 0;
position: relative;
}
.scanner-page-thumb.selected {
border-color: #5cb85c;
}
.scanner-page-wrapper {
position: relative;
flex-shrink: 0;
}
.scanner-page-delete {
position: absolute;
top: -6px;
right: -6px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #d9534f;
color: #fff;
border: none;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.scanner-page-number {
position: absolute;
bottom: 2px;
left: 2px;
background: rgba(0,0,0,0.7);
color: #fff;
font-size: 10px;
padding: 1px 4px;
border-radius: 2px;
}
.scanner-controls {
display: flex;
gap: 12px;
padding: 16px;
background: #222;
border-top: 1px solid #333;
}
.scanner-controls button {
flex: 1;
padding: 14px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
}
.scanner-btn-capture {
background: #5cb85c;
color: #fff;
}
.scanner-btn-capture:active { background: #4cae4c; }
.scanner-btn-done {
background: #337ab7;
color: #fff;
}
.scanner-btn-done:active { background: #2868a0; }
.scanner-btn-done:disabled {
background: #444;
color: #888;
cursor: not-allowed;
}
.scanner-btn-cancel {
background: #444;
color: #fff;
flex: 0.5;
}
/* Scanner Vorschau (einzelne Seite) */
.scanner-preview {
position: absolute;
inset: 0;
background: #000;
display: flex;
flex-direction: column;
z-index: 10;
}
.scanner-preview.hidden { display: none; }
.scanner-preview img {
flex: 1;
width: 100%;
object-fit: contain;
}
.scanner-preview-buttons {
display: flex;
gap: 12px;
padding: 16px;
background: #222;
}
.scanner-preview-buttons button {
flex: 1;
padding: 14px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
}
/* Upload-Fortschritt */
.scanner-uploading {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 20;
}
.scanner-uploading.hidden { display: none; }
.scanner-uploading .spinner {
width: 48px;
height: 48px;
border: 4px solid #333;
border-top-color: #5cb85c;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.scanner-uploading p {
margin-top: 16px;
color: #fff;
font-size: 14px;
}
/* ========== Bild-Viewer (Zoom + Swipe) ========== */
.pwa-viewer-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
touch-action: none;
}
.pwa-viewer-overlay.active {
opacity: 1;
visibility: visible;
}
.pwa-viewer-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.pwa-viewer-image {
max-width: 100vw;
max-height: 100vh;
object-fit: contain;
transform-origin: center center;
transition: transform 0.15s ease-out;
cursor: grab;
}
.pwa-viewer-image.dragging {
cursor: grabbing;
transition: none;
}
.pwa-viewer-close {
position: absolute;
top: 12px;
right: 12px;
width: 44px;
height: 44px;
border: none;
background: rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 28px;
line-height: 1;
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.pwa-viewer-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 50px;
height: 70px;
border: none;
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-size: 28px;
cursor: pointer;
z-index: 10;
}
.pwa-viewer-nav:disabled { opacity: 0.3; }
.pwa-viewer-nav.prev { left: 8px; border-radius: 4px; }
.pwa-viewer-nav.next { right: 8px; border-radius: 4px; }
.pwa-viewer-counter {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
color: #fff;
font-size: 14px;
background: rgba(0, 0, 0, 0.6);
padding: 6px 14px;
border-radius: 20px;
z-index: 10;
}
.pwa-viewer-zoom {
position: absolute;
bottom: 16px;
right: 12px;
display: flex;
gap: 8px;
z-index: 10;
}
.pwa-viewer-zoom button {
width: 40px;
height: 40px;
border: none;
background: rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 20px;
cursor: pointer;
border-radius: 6px;
}
/* Hochgeladene Bilder klickbar */
.uploaded-item {
cursor: pointer;
}
.uploaded-item:active {
background: rgba(255,255,255,0.1);
}
/* Scanner-Thumbnails klickbar */
.scanner-page-thumb {
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>
</head>
<body>
<div class="header">
<h1>Foto Upload</h1>
<div class="ref"><?php print htmlspecialchars($parent_ref); ?></div>
<div class="info">Token gültig noch <?php print $valid_min; ?> Min · <?php print $tok->max_uploads - $tok->uploads_count; ?> Uploads übrig</div>
</div>
<div class="action-buttons">
<button class="btn" id="open-camera-btn">Foto aufnehmen</button>
<button class="btn" id="open-scanner-btn" style="background: #5cb85c;">📄 Dokument scannen</button>
<label class="btn btn-secondary" for="gallery-input">Aus Galerie wählen</label>
<input type="file" id="gallery-input" class="hidden-input" accept="image/*" multiple>
</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 -->
<div id="camera-view" class="camera-view hidden">
<video id="camera-stream" autoplay playsinline muted></video>
<canvas id="camera-canvas"></canvas>
<!-- Kamera-Controls -->
<div class="camera-controls">
<button id="camera-close" class="camera-btn-close">✕</button>
<button id="camera-shutter" class="camera-shutter"></button>
<button id="camera-switch" class="camera-btn-switch">⟳</button>
</div>
<!-- Upload-Counter -->
<div id="camera-counter" class="camera-counter">0 Fotos</div>
<!-- Vorschau nach Foto -->
<div id="photo-preview" class="photo-preview hidden">
<img id="preview-image" src="">
<div class="preview-buttons">
<button id="preview-discard" class="btn-discard">✕ Verwerfen</button>
<button id="preview-save" class="btn-save">✓ Speichern</button>
</div>
</div>
</div>
<!-- Dokumenten-Scanner Overlay -->
<div id="scanner-view" class="scanner-view hidden">
<div class="scanner-header">
<h2>📄 Dokument scannen</h2>
<span id="scanner-page-count" class="page-count">0 Seiten</span>
</div>
<div class="scanner-camera-area">
<video id="scanner-stream" autoplay playsinline muted></video>
<canvas id="scanner-canvas" style="display:none;"></canvas>
<!-- Vorschau einer gescannten Seite -->
<div id="scanner-preview" class="scanner-preview hidden">
<img id="scanner-preview-image" src="">
<div class="scanner-preview-buttons">
<button id="scanner-preview-discard" class="btn-discard">✕ Verwerfen</button>
<button id="scanner-preview-add" class="btn-save">✓ Seite hinzufügen</button>
</div>
</div>
<!-- Upload-Fortschritt -->
<div id="scanner-uploading" class="scanner-uploading hidden">
<div class="spinner"></div>
<p id="scanner-upload-status">Dokument wird verarbeitet…</p>
</div>
</div>
<!-- Seiten-Vorschau-Leiste -->
<div id="scanner-pages-strip" class="scanner-pages-strip">
<!-- Thumbnails werden hier eingefügt -->
</div>
<div class="scanner-controls">
<button id="scanner-cancel" class="scanner-btn-cancel">✕</button>
<button id="scanner-capture" class="scanner-btn-capture">📷 Seite aufnehmen</button>
<button id="scanner-done" class="scanner-btn-done" disabled>Fertig</button>
</div>
</div>
<div id="uploading-area"></div>
<div class="uploaded-list">
<h2>Hochgeladen <span id="uploaded-count">(0)</span></h2>
<div id="uploaded-list"></div>
</div>
<script>
const TOKEN = <?php print json_encode($token); ?>;
const URL_UPLOAD = window.location.pathname + '?token=' + encodeURIComponent(TOKEN);
let uploadedCount = 0;
// DOM-Elemente
const galleryInput = document.getElementById('gallery-input');
const uploadingArea = document.getElementById('uploading-area');
const uploadedList = document.getElementById('uploaded-list');
const uploadedCountEl = document.getElementById('uploaded-count');
// Kamera-Elemente
const openCameraBtn = document.getElementById('open-camera-btn');
const cameraView = document.getElementById('camera-view');
const cameraStream = document.getElementById('camera-stream');
const cameraCanvas = document.getElementById('camera-canvas');
const cameraClose = document.getElementById('camera-close');
const cameraShutter = document.getElementById('camera-shutter');
const cameraSwitch = document.getElementById('camera-switch');
const cameraCounter = document.getElementById('camera-counter');
const photoPreview = document.getElementById('photo-preview');
const previewImage = document.getElementById('preview-image');
const previewDiscard = document.getElementById('preview-discard');
const previewSave = document.getElementById('preview-save');
// Kamera-Status
let mediaStream = null;
let currentFacingMode = 'environment'; // 'environment' = Rückkamera, 'user' = Frontkamera
let currentPhotoBlob = null;
let cameraSessionCount = 0;
// Galerie-Input Handler
galleryInput.addEventListener('change', async () => {
if (!galleryInput.files || !galleryInput.files.length) return;
for (const file of galleryInput.files) {
await uploadFile(file);
}
galleryInput.value = '';
});
// === KAMERA-FUNKTIONEN ===
// Kamera öffnen
openCameraBtn.addEventListener('click', openCameraView);
async function openCameraView() {
try {
const constraints = {
video: {
facingMode: currentFacingMode,
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false
};
mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
cameraStream.srcObject = mediaStream;
cameraView.classList.remove('hidden');
cameraSessionCount = 0;
updateCameraCounter();
document.body.style.overflow = 'hidden';
} catch (err) {
console.error('Kamera-Fehler:', err);
toast('Kamera nicht verfügbar: ' + err.message, true);
}
}
// Kamera schließen
cameraClose.addEventListener('click', closeCameraView);
function closeCameraView() {
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
mediaStream = null;
}
cameraStream.srcObject = null;
cameraView.classList.add('hidden');
photoPreview.classList.add('hidden');
document.body.style.overflow = '';
}
// Foto aufnehmen
cameraShutter.addEventListener('click', takePhoto);
function takePhoto() {
if (!mediaStream) return;
const video = cameraStream;
const canvas = cameraCanvas;
// Canvas auf Video-Größe setzen
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Video-Frame auf Canvas zeichnen
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
// Canvas zu Blob konvertieren
canvas.toBlob((blob) => {
if (blob) {
currentPhotoBlob = blob;
showPreview(blob);
}
}, 'image/jpeg', 0.85);
}
// Vorschau anzeigen
function showPreview(blob) {
const url = URL.createObjectURL(blob);
previewImage.onload = () => URL.revokeObjectURL(url);
previewImage.src = url;
photoPreview.classList.remove('hidden');
}
// Foto verwerfen
previewDiscard.addEventListener('click', discardPhoto);
function discardPhoto() {
currentPhotoBlob = null;
photoPreview.classList.add('hidden');
}
// Foto speichern und Kamera offen lassen
previewSave.addEventListener('click', savePhoto);
async function savePhoto() {
if (!currentPhotoBlob) return;
// Vorschau schließen, zurück zur Kamera
photoPreview.classList.add('hidden');
// Upload im Hintergrund
const blob = currentPhotoBlob;
currentPhotoBlob = null;
const fname = 'foto_' + Date.now() + '.jpg';
const fd = new FormData();
fd.append('token', TOKEN);
fd.append('file', blob, fname);
try {
const r = await fetch(URL_UPLOAD, { method: 'POST', body: fd });
const data = await r.json();
if (data.success) {
uploadedCount++;
cameraSessionCount++;
uploadedCountEl.textContent = '(' + uploadedCount + ')';
updateCameraCounter();
// Item zur Liste hinzufügen
const item = document.createElement('div');
item.className = 'uploaded-item';
item.innerHTML = '<span class="check">✓</span><span class="name">' + escapeHtml(data.filename) + '</span>';
uploadedList.prepend(item);
toast('Foto gespeichert');
// Galerie aktualisieren
if (typeof loadGalleryPhotos === 'function') {
setTimeout(loadGalleryPhotos, 300);
}
} else {
toast('Fehler: ' + (data.error || 'unbekannt'), true);
}
} catch (e) {
toast('Netzwerkfehler', true);
}
}
// Kamera wechseln (Front/Back)
cameraSwitch.addEventListener('click', switchCamera);
async function switchCamera() {
currentFacingMode = currentFacingMode === 'environment' ? 'user' : 'environment';
// Alten Stream stoppen
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
}
// Neuen Stream starten
try {
const constraints = {
video: {
facingMode: currentFacingMode,
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false
};
mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
cameraStream.srcObject = mediaStream;
} catch (err) {
console.error('Kamera-Wechsel fehlgeschlagen:', err);
toast('Kamera-Wechsel fehlgeschlagen', true);
// Zurück zur anderen Kamera
currentFacingMode = currentFacingMode === 'environment' ? 'user' : 'environment';
}
}
function updateCameraCounter() {
const text = cameraSessionCount === 1 ? '1 Foto' : cameraSessionCount + ' Fotos';
cameraCounter.textContent = text;
}
// === DOKUMENTEN-SCANNER ===
const openScannerBtn = document.getElementById('open-scanner-btn');
const scannerView = document.getElementById('scanner-view');
const scannerStream = document.getElementById('scanner-stream');
const scannerCanvas = document.getElementById('scanner-canvas');
const scannerPreview = document.getElementById('scanner-preview');
const scannerPreviewImage = document.getElementById('scanner-preview-image');
const scannerPreviewDiscard = document.getElementById('scanner-preview-discard');
const scannerPreviewAdd = document.getElementById('scanner-preview-add');
const scannerPagesStrip = document.getElementById('scanner-pages-strip');
const scannerPageCount = document.getElementById('scanner-page-count');
const scannerCapture = document.getElementById('scanner-capture');
const scannerDone = document.getElementById('scanner-done');
const scannerCancel = document.getElementById('scanner-cancel');
const scannerUploading = document.getElementById('scanner-uploading');
const scannerUploadStatus = document.getElementById('scanner-upload-status');
let scannerMediaStream = null;
let scannedPages = []; // Array von Blobs
let currentScanBlob = null;
// Scanner öffnen
openScannerBtn.addEventListener('click', openScannerView);
async function openScannerView() {
try {
const constraints = {
video: {
facingMode: 'environment',
width: { ideal: 2560 },
height: { ideal: 1920 }
},
audio: false
};
scannerMediaStream = await navigator.mediaDevices.getUserMedia(constraints);
scannerStream.srcObject = scannerMediaStream;
scannerView.classList.remove('hidden');
scannedPages = [];
updateScannerUI();
document.body.style.overflow = 'hidden';
} catch (err) {
console.error('Scanner-Fehler:', err);
toast('Kamera nicht verfügbar: ' + err.message, true);
}
}
// Scanner schließen
scannerCancel.addEventListener('click', closeScannerView);
function closeScannerView() {
if (scannerMediaStream) {
scannerMediaStream.getTracks().forEach(track => track.stop());
scannerMediaStream = null;
}
scannerStream.srcObject = null;
scannerView.classList.add('hidden');
scannerPreview.classList.add('hidden');
scannerUploading.classList.add('hidden');
// Alte Seiten-Blobs freigeben
scannedPages.forEach(p => URL.revokeObjectURL(p.url));
scannedPages = [];
document.body.style.overflow = '';
}
// Seite aufnehmen
scannerCapture.addEventListener('click', captureScanPage);
function captureScanPage() {
if (!scannerMediaStream) return;
const video = scannerStream;
const canvas = scannerCanvas;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
currentScanBlob = blob;
showScanPreview(blob);
}
}, 'image/jpeg', 0.92);
}
// Vorschau anzeigen
function showScanPreview(blob) {
const url = URL.createObjectURL(blob);
scannerPreviewImage.onload = () => URL.revokeObjectURL(url);
scannerPreviewImage.src = url;
scannerPreview.classList.remove('hidden');
}
// Seite verwerfen
scannerPreviewDiscard.addEventListener('click', () => {
currentScanBlob = null;
scannerPreview.classList.add('hidden');
});
// Seite hinzufügen
scannerPreviewAdd.addEventListener('click', () => {
if (!currentScanBlob) return;
const url = URL.createObjectURL(currentScanBlob);
scannedPages.push({ blob: currentScanBlob, url: url });
currentScanBlob = null;
scannerPreview.classList.add('hidden');
updateScannerUI();
});
// UI aktualisieren
function updateScannerUI() {
// Counter aktualisieren
const count = scannedPages.length;
scannerPageCount.textContent = count === 1 ? '1 Seite' : count + ' Seiten';
scannerDone.disabled = count === 0;
// Thumbnails rendern
scannerPagesStrip.innerHTML = '';
scannedPages.forEach((page, index) => {
const wrapper = document.createElement('div');
wrapper.className = 'scanner-page-wrapper';
const img = document.createElement('img');
img.className = 'scanner-page-thumb';
img.src = page.url;
const numLabel = document.createElement('span');
numLabel.className = 'scanner-page-number';
numLabel.textContent = index + 1;
const delBtn = document.createElement('button');
delBtn.className = 'scanner-page-delete';
delBtn.textContent = '✕';
delBtn.onclick = () => {
URL.revokeObjectURL(page.url);
scannedPages.splice(index, 1);
updateScannerUI();
};
wrapper.appendChild(img);
wrapper.appendChild(numLabel);
wrapper.appendChild(delBtn);
scannerPagesStrip.appendChild(wrapper);
});
}
// Dokument fertigstellen und hochladen
scannerDone.addEventListener('click', finishScanning);
async function finishScanning() {
if (scannedPages.length === 0) return;
scannerUploading.classList.remove('hidden');
scannerUploadStatus.textContent = 'Dokument wird hochgeladen…';
const fd = new FormData();
fd.append('token', TOKEN);
fd.append('action', 'scan_document');
// Alle Seiten hinzufügen
scannedPages.forEach((page, i) => {
fd.append('pages[]', page.blob, 'page_' + (i + 1) + '.jpg');
});
try {
const url = window.location.pathname.replace('mobile_upload.php', 'ajax/process_document.php');
scannerUploadStatus.textContent = 'OCR-Verarbeitung läuft…';
const r = await fetch(url + '?token=' + encodeURIComponent(TOKEN), {
method: 'POST',
body: fd
});
const data = await r.json();
if (data.success) {
uploadedCount++;
uploadedCountEl.textContent = '(' + uploadedCount + ')';
const item = document.createElement('div');
item.className = 'uploaded-item';
item.innerHTML = '<span class="check">✓</span><span class="name">📄 ' + escapeHtml(data.filename) + '</span>';
uploadedList.prepend(item);
toast('Dokument erstellt: ' + scannedPages.length + ' Seiten');
closeScannerView();
// Galerie aktualisieren
if (typeof loadGalleryPhotos === 'function') {
setTimeout(loadGalleryPhotos, 300);
}
} else {
scannerUploading.classList.add('hidden');
toast('Fehler: ' + (data.error || 'unbekannt'), true);
}
} catch (e) {
console.error('Upload-Fehler:', e);
scannerUploading.classList.add('hidden');
toast('Netzwerkfehler', true);
}
}
// === UPLOAD-FUNKTIONEN ===
async function uploadFile(file) {
const blob = await resizeImage(file, 2000);
const fname = file.name || 'photo.jpg';
const status = document.createElement('div');
status.className = 'uploading';
status.textContent = fname + ' wird hochgeladen…';
uploadingArea.appendChild(status);
const fd = new FormData();
fd.append('token', TOKEN);
fd.append('file', blob, fname);
try {
const r = await fetch(URL_UPLOAD, { method: 'POST', body: fd });
const data = await r.json();
status.remove();
if (data.success) {
uploadedCount++;
uploadedCountEl.textContent = '(' + uploadedCount + ')';
const item = document.createElement('div');
item.className = 'uploaded-item';
item.innerHTML = '<span class="check">✓</span><span class="name">' + escapeHtml(data.filename) + '</span>';
uploadedList.prepend(item);
toast('Hochgeladen: ' + fname);
// Galerie aktualisieren
if (typeof loadGalleryPhotos === 'function') {
setTimeout(loadGalleryPhotos, 300);
}
} else {
toast('Fehler: ' + (data.error || 'unbekannt'), true);
}
} catch (e) {
status.remove();
toast('Netzwerkfehler', true);
}
}
async function resizeImage(file, maxSide) {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const scale = Math.min(1, maxSide / Math.max(img.width, img.height));
if (scale === 1) {
resolve(file);
return;
}
const c = document.createElement('canvas');
c.width = Math.round(img.width * scale);
c.height = Math.round(img.height * scale);
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
c.toBlob((blob) => resolve(blob || file), 'image/jpeg', 0.85);
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(file); };
img.src = url;
});
}
function toast(msg, isError) {
const t = document.createElement('div');
t.className = 'toast' + (isError ? ' error' : '');
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2500);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// === BILD-VIEWER mit Zoom und Swipe ===
const pwaViewer = (function() {
let images = [];
let currentIndex = 0;
let zoom = 1;
let panX = 0, panY = 0;
let overlay, container, img, closeBtn, prevBtn, nextBtn, counter;
let touchState = { startX: 0, startY: 0, startDist: 0, startZoom: 1, startPanX: 0, startPanY: 0, isDragging: false, isPinching: false, lastTap: 0 };
function init() {
// DOM erstellen
overlay = document.createElement('div');
overlay.className = 'pwa-viewer-overlay';
overlay.innerHTML = `
<div class="pwa-viewer-container">
<img class="pwa-viewer-image" src="" draggable="false">
</div>
<button type="button" class="pwa-viewer-close">&times;</button>
<button type="button" class="pwa-viewer-nav prev">&#8249;</button>
<button type="button" class="pwa-viewer-nav next">&#8250;</button>
<div class="pwa-viewer-counter">1 / 1</div>
<div class="pwa-viewer-zoom">
<button data-action="out"></button>
<button data-action="reset">⟳</button>
<button data-action="in">+</button>
</div>
`;
document.body.appendChild(overlay);
container = overlay.querySelector('.pwa-viewer-container');
img = overlay.querySelector('.pwa-viewer-image');
closeBtn = overlay.querySelector('.pwa-viewer-close');
prevBtn = overlay.querySelector('.pwa-viewer-nav.prev');
nextBtn = overlay.querySelector('.pwa-viewer-nav.next');
counter = overlay.querySelector('.pwa-viewer-counter');
// Events
closeBtn.onclick = close;
overlay.onclick = (e) => { if (e.target === overlay || e.target === container) close(); };
prevBtn.onclick = (e) => { e.stopPropagation(); prev(); };
nextBtn.onclick = (e) => { e.stopPropagation(); next(); };
overlay.querySelector('.pwa-viewer-zoom').onclick = (e) => {
e.stopPropagation();
const a = e.target.dataset.action;
if (a === 'in') setZoom(zoom + 0.5);
else if (a === 'out') setZoom(zoom - 0.5);
else if (a === 'reset') resetZoom();
};
// Touch
container.addEventListener('touchstart', handleTouchStart, { passive: false });
container.addEventListener('touchmove', handleTouchMove, { passive: false });
container.addEventListener('touchend', handleTouchEnd);
// Doppelklick
img.addEventListener('dblclick', (e) => {
e.preventDefault();
if (zoom > 1) resetZoom();
else setZoom(2.5);
});
}
function open(imgs, idx) {
images = imgs;
currentIndex = Math.max(0, Math.min(idx || 0, imgs.length - 1));
resetZoom();
loadCurrent();
overlay.classList.add('active');
document.body.style.overflow = 'hidden';
}
function close() {
overlay.classList.remove('active');
document.body.style.overflow = '';
}
function next() { if (currentIndex < images.length - 1) { currentIndex++; resetZoom(); loadCurrent(); } }
function prev() { if (currentIndex > 0) { currentIndex--; resetZoom(); loadCurrent(); } }
function loadCurrent() {
img.src = images[currentIndex].url;
counter.textContent = (currentIndex + 1) + ' / ' + images.length;
prevBtn.disabled = currentIndex === 0;
nextBtn.disabled = currentIndex === images.length - 1;
prevBtn.style.display = nextBtn.style.display = images.length > 1 ? '' : 'none';
}
function setZoom(z) {
zoom = Math.max(0.5, Math.min(5, z));
constrainPan();
applyTransform();
}
function resetZoom() { zoom = 1; panX = 0; panY = 0; applyTransform(); }
function constrainPan() {
if (zoom <= 1) { panX = 0; panY = 0; return; }
const rect = container.getBoundingClientRect();
const maxX = Math.max(0, (img.naturalWidth * zoom - rect.width) / 2);
const maxY = Math.max(0, (img.naturalHeight * zoom - rect.height) / 2);
panX = Math.max(-maxX, Math.min(maxX, panX));
panY = Math.max(-maxY, Math.min(maxY, panY));
}
function applyTransform() {
img.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
}
function handleTouchStart(e) {
if (e.touches.length === 1) {
const t = e.touches[0];
touchState.startX = t.clientX;
touchState.startY = t.clientY;
touchState.startPanX = panX;
touchState.startPanY = panY;
touchState.isDragging = true;
touchState.isPinching = false;
// Doppeltap
const now = Date.now();
if (now - touchState.lastTap < 300) {
if (zoom > 1) resetZoom(); else setZoom(2.5);
touchState.lastTap = 0;
} else {
touchState.lastTap = now;
}
} else if (e.touches.length === 2) {
e.preventDefault();
touchState.isPinching = true;
touchState.isDragging = false;
touchState.startDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
touchState.startZoom = zoom;
}
}
function handleTouchMove(e) {
if (touchState.isPinching && e.touches.length === 2) {
e.preventDefault();
const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
setZoom(touchState.startZoom * (dist / touchState.startDist));
} else if (touchState.isDragging && e.touches.length === 1) {
const t = e.touches[0];
const dx = t.clientX - touchState.startX;
const dy = t.clientY - touchState.startY;
if (zoom > 1) {
e.preventDefault();
panX = touchState.startPanX + dx;
panY = touchState.startPanY + dy;
constrainPan();
applyTransform();
}
}
}
function handleTouchEnd(e) {
if (touchState.isDragging && zoom <= 1 && e.changedTouches.length > 0) {
const t = e.changedTouches[0];
const dx = t.clientX - touchState.startX;
if (Math.abs(dx) > 50) {
if (dx > 0) prev(); else next();
}
}
touchState.isDragging = false;
touchState.isPinching = false;
}
init();
return { open, close };
})();
// Hochgeladene Bilder klickbar machen
uploadedList.addEventListener('click', (e) => {
const item = e.target.closest('.uploaded-item');
if (!item) return;
// Alle Bilder in der Liste sammeln
const items = Array.from(uploadedList.querySelectorAll('.uploaded-item'));
const images = items.map(it => {
const name = it.querySelector('.name')?.textContent || '';
// Bild-URL aus dem Upload-Ordner (über Token-API holen wäre besser, aber als Fallback filename)
return { url: '', title: name };
}).filter(i => i.title);
// Da wir die URL nicht haben, zeigen wir nur eine Meldung
// Die Bilder sind erst nach Upload auf dem Server, nicht mehr lokal verfügbar
toast('Bilder nur im Dolibarr-Editor ansehbar');
});
// Scanner-Thumbs klickbar machen (diese haben noch die lokale URL)
scannerPagesStrip.addEventListener('click', (e) => {
const thumb = e.target.closest('.scanner-page-thumb');
if (!thumb) return;
// Alle Scanner-Seiten als Bilder
const images = scannedPages.map((p, i) => ({ url: p.url, title: 'Seite ' + (i + 1) }));
const idx = Array.from(scannerPagesStrip.querySelectorAll('.scanner-page-thumb')).indexOf(thumb);
if (images.length > 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>
</body>
</html>