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
This commit is contained in:
@@ -123,6 +123,12 @@ html_navbar('accounts');
|
||||
<div class="container-fluid">
|
||||
<?php flash_render(); ?>
|
||||
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<a href="/admin/qrcode.php" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-qr-code me-1"></i>QR Codes téléversement
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- Formulaire création -->
|
||||
|
||||
106
web/admin/qrcode.php
Normal file
106
web/admin/qrcode.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/layout.php';
|
||||
|
||||
admin_check();
|
||||
|
||||
$accounts = db()->query('SELECT id, username, display_name FROM accounts WHERE active = 1 ORDER BY username')->fetchAll();
|
||||
|
||||
html_head('QR Codes Upload');
|
||||
html_navbar('accounts');
|
||||
?>
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-qr-code me-2"></i>QR Codes — Téléversement mobile</h5>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/admin/accounts.php" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Comptes
|
||||
</a>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="window.print()">
|
||||
<i class="bi bi-printer me-1"></i>Imprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Imprimez et affichez ces QR codes au comptoir. Le client scanne avec son téléphone et envoie ses documents directement.
|
||||
</div>
|
||||
|
||||
<div class="row g-4" id="qr-grid">
|
||||
<!-- QR code général -->
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<div class="card shadow-sm text-center qr-card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-muted mb-1">
|
||||
<i class="bi bi-globe me-1"></i>Tous les postes
|
||||
</h6>
|
||||
<p class="small text-muted mb-3">Le client choisit le poste sur la page</p>
|
||||
<div id="qr-general" class="qr-container mb-2"></div>
|
||||
<code class="small text-break" id="url-general"></code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php foreach ($accounts as $acc): ?>
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<div class="card shadow-sm text-center qr-card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<i class="bi bi-pc-display me-1 text-primary"></i>
|
||||
<?= htmlspecialchars($acc['display_name'] ?: $acc['username']) ?>
|
||||
</h6>
|
||||
<?php if ($acc['display_name']): ?>
|
||||
<p class="small text-muted mb-3"><?= htmlspecialchars($acc['username']) ?></p>
|
||||
<?php else: ?>
|
||||
<p class="small text-muted mb-3">Accès direct à ce poste</p>
|
||||
<?php endif; ?>
|
||||
<div id="qr-<?= $acc['id'] ?>" class="qr-container mb-2"></div>
|
||||
<code class="small text-break" id="url-<?= $acc['id'] ?>"></code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.qr-card { border-radius: 12px; }
|
||||
.qr-container { display: flex; justify-content: center; }
|
||||
.qr-container canvas { border-radius: 8px; }
|
||||
|
||||
@media print {
|
||||
.navbar, .alert, .btn, .d-flex.justify-content-between .d-flex { display: none !important; }
|
||||
.col-sm-6 { break-inside: avoid; }
|
||||
.qr-card { border: 2px solid #000 !important; box-shadow: none !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<script>
|
||||
const BASE = window.location.origin;
|
||||
const accounts = <?= json_encode($accounts) ?>;
|
||||
|
||||
function makeQR(elementId, url, urlDisplayId) {
|
||||
new QRCode(document.getElementById(elementId), {
|
||||
text: url,
|
||||
width: 200,
|
||||
height: 200,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
if (urlDisplayId) {
|
||||
document.getElementById(urlDisplayId).textContent = url;
|
||||
}
|
||||
}
|
||||
|
||||
makeQR('qr-general', BASE + '/upload.php', 'url-general');
|
||||
|
||||
accounts.forEach(acc => {
|
||||
const url = BASE + '/upload.php?to=' + encodeURIComponent(acc.username);
|
||||
makeQR('qr-' + acc.id, url, 'url-' + acc.id);
|
||||
});
|
||||
</script>
|
||||
<?php html_foot(); ?>
|
||||
104
web/api/upload.php
Normal file
104
web/api/upload.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Méthode non autorisée']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$accountId = (int)($_POST['account_id'] ?? 0);
|
||||
if (!$accountId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Compte non spécifié']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$st = db()->prepare('SELECT id, username, smb_path, webhook_url FROM accounts WHERE id = ? AND active = 1');
|
||||
$st->execute([$accountId]);
|
||||
$account = $st->fetch();
|
||||
if (!$account) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Compte introuvable ou inactif']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($_FILES['files']) || !is_array($_FILES['files']['name'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Aucun fichier envoyé']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$destDir = $account['smb_path'];
|
||||
if (!is_dir($destDir)) mkdir($destDir, 0750, true);
|
||||
|
||||
$st = db()->prepare('INSERT INTO source_file (account_id, title, sender, file_name) VALUES (?, ?, ?, ?)');
|
||||
$st->execute([$account['id'], 'Dépôt comptoir', 'Téléversement mobile', 'upload_' . time()]);
|
||||
$sourceId = (int)db()->lastInsertId();
|
||||
|
||||
$uploaded = 0;
|
||||
$errors = [];
|
||||
$fileCount = count($_FILES['files']['name']);
|
||||
|
||||
for ($i = 0; $i < $fileCount; $i++) {
|
||||
if ($_FILES['files']['error'][$i] !== UPLOAD_ERR_OK) {
|
||||
$errors[] = $_FILES['files']['name'][$i] . ' : erreur upload';
|
||||
continue;
|
||||
}
|
||||
|
||||
$origName = $_FILES['files']['name'][$i];
|
||||
$tmpPath = $_FILES['files']['tmp_name'][$i];
|
||||
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9._\- ]/', '_', $origName);
|
||||
$destPath = $destDir . '/' . $safeName;
|
||||
|
||||
if (file_exists($destPath)) {
|
||||
$ext = pathinfo($safeName, PATHINFO_EXTENSION);
|
||||
$base = pathinfo($safeName, PATHINFO_FILENAME);
|
||||
$destPath = $destDir . '/' . $base . '_' . time() . ($ext ? '.' . $ext : '');
|
||||
$safeName = basename($destPath);
|
||||
}
|
||||
|
||||
if (move_uploaded_file($tmpPath, $destPath)) {
|
||||
chmod($destPath, 0644);
|
||||
db()->prepare('INSERT INTO files (source_file_id, file_name, path) VALUES (?, ?, ?)')
|
||||
->execute([$sourceId, $safeName, $destPath]);
|
||||
$uploaded++;
|
||||
} else {
|
||||
$errors[] = $origName . ' : échec écriture';
|
||||
}
|
||||
}
|
||||
|
||||
if ($uploaded === 0) {
|
||||
db()->prepare('DELETE FROM source_file WHERE id = ?')->execute([$sourceId]);
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Aucun fichier enregistré', 'details' => $errors]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!empty($account['webhook_url'])) {
|
||||
$ctx = stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\n",
|
||||
'content' => json_encode([
|
||||
'account' => $account['username'],
|
||||
'subject' => 'Dépôt comptoir',
|
||||
'from' => 'Téléversement mobile',
|
||||
'source_id' => $sourceId,
|
||||
'files' => $uploaded,
|
||||
'received_at' => date('c'),
|
||||
]),
|
||||
'timeout' => 5,
|
||||
'ignore_errors' => true,
|
||||
]]);
|
||||
@file_get_contents($account['webhook_url'], false, $ctx);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'uploaded' => $uploaded,
|
||||
'account' => $account['username'],
|
||||
'errors' => $errors,
|
||||
]);
|
||||
407
web/upload.php
Normal file
407
web/upload.php
Normal file
@@ -0,0 +1,407 @@
|
||||
<?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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user