Files
copymailrpi/web/upload.php
Jules 8dd0e471fd feat: téléversement mobile par QR code — alternative AirDrop pour clients en magasin
Page publique /upload.php (mobile-first, sans auth) : le client scanne un QR code
au comptoir, choisit le poste de destination, et envoie ses documents.
Les fichiers sont déposés dans le share SMB du compte, enregistrés en BDD
(mêmes tables que les mails) et déclenchent le webhook si configuré.

Page admin /admin/qrcode.php pour générer/imprimer les QR codes par poste.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SChGCatJdbmZKCyYvTJMf
2026-09-05 15:13:37 +02:00

408 lines
14 KiB
PHP

<?php
require_once __DIR__ . '/includes/db.php';
$accounts = db()->query('SELECT id, username, display_name FROM accounts WHERE active = 1 ORDER BY username')->fetchAll();
$preselect = $_GET['to'] ?? '';
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Envoyer un document — CopyMail</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<style>
* { -webkit-tap-highlight-color: transparent; }
body { background: #f0f2f8; min-height: 100dvh; }
.brand-bar {
background: #0d6efd;
color: #fff;
padding: 1rem;
text-align: center;
font-size: 1.2rem;
font-weight: 600;
}
.brand-bar i { margin-right: .5rem; }
.step { padding: 1.25rem; max-width: 600px; margin: 0 auto; }
.account-card {
background: #fff;
border: 2px solid #dee2e6;
border-radius: 12px;
padding: 1.25rem 1rem;
text-align: center;
cursor: pointer;
transition: all .15s;
user-select: none;
-webkit-user-select: none;
}
.account-card:active,
.account-card.selected {
border-color: #0d6efd;
background: #e8f0fe;
transform: scale(0.97);
}
.account-card .icon {
font-size: 2rem;
color: #0d6efd;
margin-bottom: .5rem;
}
.account-card .name {
font-weight: 600;
font-size: 1.05rem;
}
.account-card .sub {
color: #6c757d;
font-size: .85rem;
}
.upload-zone {
border: 3px dashed #c0c8d4;
border-radius: 16px;
padding: 2.5rem 1rem;
text-align: center;
background: #fff;
transition: border-color .2s, background .2s;
}
.upload-zone.dragover {
border-color: #0d6efd;
background: #e8f0fe;
}
.upload-zone .big-icon {
font-size: 3.5rem;
color: #adb5bd;
margin-bottom: .5rem;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 8px;
padding: .6rem .8rem;
margin-top: .5rem;
border: 1px solid #e9ecef;
}
.file-item .file-info {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: .5rem;
}
.progress { height: 8px; border-radius: 4px; }
.success-icon {
font-size: 5rem;
color: #198754;
animation: pop .4s ease;
}
@keyframes pop {
0% { transform: scale(0); opacity: 0; }
70% { transform: scale(1.15); }
100% { transform: scale(1); opacity: 1; }
}
.btn-file {
position: relative;
overflow: hidden;
}
.btn-file input[type=file] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
font-size: 100px;
}
.dest-badge {
display: inline-flex;
align-items: center;
gap: .4rem;
background: #e8f0fe;
color: #0d6efd;
border-radius: 8px;
padding: .4rem .8rem;
font-weight: 600;
}
</style>
</head>
<body>
<div class="brand-bar">
<i class="bi bi-envelope-arrow-down-fill"></i>CopyMail
</div>
<!-- Step 1: Select account -->
<div id="step-select" class="step">
<h5 class="text-center mb-3 text-muted">
<i class="bi bi-cursor-fill me-1"></i>Sélectionnez le poste
</h5>
<div class="row g-3">
<?php foreach ($accounts as $acc): ?>
<div class="col-6">
<div class="account-card" data-id="<?= $acc['id'] ?>"
data-name="<?= htmlspecialchars($acc['display_name'] ?: $acc['username']) ?>"
data-username="<?= htmlspecialchars($acc['username']) ?>">
<div class="icon"><i class="bi bi-pc-display"></i></div>
<div class="name"><?= htmlspecialchars($acc['display_name'] ?: $acc['username']) ?></div>
<?php if ($acc['display_name']): ?>
<div class="sub"><?= htmlspecialchars($acc['username']) ?></div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($accounts)): ?>
<div class="col-12 text-center text-muted py-4">
<i class="bi bi-exclamation-circle me-1"></i>Aucun poste disponible.
</div>
<?php endif; ?>
</div>
</div>
<!-- Step 2: Upload files -->
<div id="step-upload" class="step" style="display:none">
<div class="d-flex align-items-center justify-content-between mb-3">
<div class="dest-badge">
<i class="bi bi-pc-display"></i>
<span id="selected-name"></span>
</div>
<button class="btn btn-sm btn-outline-secondary" onclick="goBack()">
<i class="bi bi-arrow-left me-1"></i>Changer
</button>
</div>
<div class="upload-zone" id="drop-zone">
<div class="big-icon"><i class="bi bi-cloud-arrow-up-fill"></i></div>
<p class="text-muted mb-3">Sélectionnez vos documents</p>
<div class="d-flex flex-column gap-2 align-items-center">
<label class="btn btn-primary btn-lg btn-file">
<i class="bi bi-folder2-open me-2"></i>Choisir un fichier
<input type="file" id="file-input" multiple>
</label>
<label class="btn btn-outline-secondary btn-file">
<i class="bi bi-camera me-2"></i>Prendre une photo
<input type="file" id="camera-input" accept="image/*" capture="environment" multiple>
</label>
</div>
</div>
<div id="file-list"></div>
<button id="btn-send" class="btn btn-success btn-lg w-100 mt-3" style="display:none" onclick="doUpload()">
<i class="bi bi-send-fill me-2"></i>Envoyer <span id="file-count-badge"></span>
</button>
<div id="progress-wrapper" class="mt-3" style="display:none">
<div class="progress">
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-success" style="width:0%"></div>
</div>
<p class="text-center text-muted small mt-2">Envoi en cours…</p>
</div>
</div>
<!-- Step 3: Done -->
<div id="step-done" class="step" style="display:none">
<div class="text-center py-4">
<div class="success-icon"><i class="bi bi-check-circle-fill"></i></div>
<h4 class="mt-3">Document(s) envoyé(s)</h4>
<p class="text-muted" id="done-msg"></p>
<button class="btn btn-primary btn-lg mt-3" onclick="resetAll()">
<i class="bi bi-arrow-repeat me-2"></i>Envoyer un autre document
</button>
</div>
</div>
<!-- Error toast -->
<div class="position-fixed bottom-0 start-50 translate-middle-x p-3" style="z-index:9999">
<div id="toast-el" class="toast align-items-center text-bg-danger border-0" role="alert">
<div class="d-flex">
<div class="toast-body" id="toast-body"></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
const PRESELECT = <?= json_encode($preselect) ?>;
let selectedId = null;
let selectedName = '';
let selectedFiles = [];
document.querySelectorAll('.account-card').forEach(card => {
card.addEventListener('click', () => selectAccount(card));
});
if (PRESELECT) {
const card = document.querySelector(`.account-card[data-username="${CSS.escape(PRESELECT)}"]`);
if (card) selectAccount(card);
}
function selectAccount(card) {
selectedId = card.dataset.id;
selectedName = card.dataset.name;
document.getElementById('selected-name').textContent = selectedName;
show('step-upload');
hide('step-select');
}
function goBack() {
selectedFiles = [];
renderFiles();
show('step-select');
hide('step-upload');
}
function show(id) { document.getElementById(id).style.display = ''; }
function hide(id) { document.getElementById(id).style.display = 'none'; }
function showToast(msg) {
document.getElementById('toast-body').textContent = msg;
bootstrap.Toast.getOrCreateInstance(document.getElementById('toast-el'), {delay: 5000}).show();
}
// File selection
document.getElementById('file-input').addEventListener('change', onFilesSelected);
document.getElementById('camera-input').addEventListener('change', onFilesSelected);
function onFilesSelected(e) {
for (const f of e.target.files) {
if (!selectedFiles.some(s => s.name === f.name && s.size === f.size)) {
selectedFiles.push(f);
}
}
e.target.value = '';
renderFiles();
}
// Drag & drop (desktop bonus)
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('dragover');
for (const f of e.dataTransfer.files) {
if (!selectedFiles.some(s => s.name === f.name && s.size === f.size)) {
selectedFiles.push(f);
}
}
renderFiles();
});
function renderFiles() {
const list = document.getElementById('file-list');
const btn = document.getElementById('btn-send');
const badge = document.getElementById('file-count-badge');
if (!selectedFiles.length) {
list.innerHTML = '';
btn.style.display = 'none';
return;
}
btn.style.display = '';
badge.textContent = `(${selectedFiles.length})`;
list.innerHTML = selectedFiles.map((f, i) => {
const size = f.size < 1024*1024
? (f.size / 1024).toFixed(0) + ' Ko'
: (f.size / 1024 / 1024).toFixed(1) + ' Mo';
const icon = f.type.startsWith('image/') ? 'bi-image' :
f.type === 'application/pdf' ? 'bi-file-pdf' : 'bi-file-earmark';
return `<div class="file-item">
<div class="file-info">
<i class="bi ${icon} me-2 text-primary"></i>
<span class="small">${escHtml(f.name)}</span>
<span class="text-muted small ms-1">(${size})</span>
</div>
<button class="btn btn-sm btn-outline-danger border-0" onclick="removeFile(${i})">
<i class="bi bi-x-lg"></i>
</button>
</div>`;
}).join('');
}
function removeFile(idx) {
selectedFiles.splice(idx, 1);
renderFiles();
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function doUpload() {
if (!selectedFiles.length || !selectedId) return;
const fd = new FormData();
fd.append('account_id', selectedId);
selectedFiles.forEach(f => fd.append('files[]', f));
const btn = document.getElementById('btn-send');
const progress = document.getElementById('progress-wrapper');
const bar = document.getElementById('progress-bar');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Envoi…';
progress.style.display = '';
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload.php');
xhr.upload.addEventListener('progress', e => {
if (e.lengthComputable) {
bar.style.width = Math.round(e.loaded / e.total * 100) + '%';
}
});
xhr.onload = function() {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-send-fill me-2"></i>Envoyer';
progress.style.display = 'none';
try {
const data = JSON.parse(xhr.responseText);
if (data.success) {
document.getElementById('done-msg').textContent =
`${data.uploaded} fichier(s) envoyé(s) vers ${data.account}.`;
hide('step-upload');
show('step-done');
} else {
showToast(data.error || 'Erreur lors de l\'envoi');
}
} catch {
showToast('Erreur serveur');
}
};
xhr.onerror = function() {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-send-fill me-2"></i>Envoyer';
progress.style.display = 'none';
showToast('Erreur réseau — vérifiez votre connexion');
};
xhr.send(fd);
}
function resetAll() {
selectedFiles = [];
selectedId = null;
renderFiles();
hide('step-done');
hide('step-upload');
show('step-select');
document.getElementById('progress-wrapper').style.display = 'none';
document.getElementById('progress-bar').style.width = '0%';
}
</script>
</body>
</html>