refactor: vérification des conflits upload à la soumission (AJAX)

Au lieu d'un sélecteur statique pré-affiché, le formulaire d'upload
vérifie maintenant les conflits côté serveur au clic sur Envoyer :
- Aucun conflit → envoi immédiat sans interruption
- Conflits détectés → panneau jaune avec la liste des fichiers existants
  et un sélecteur segmenté Écraser / Backup / Renommer / Ignorer
- L'utilisateur confirme ou annule avant que le formulaire parte

Ajout de la route POST /check-upload (retourne {"conflicts": [...]}).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alpinux 2026-05-06 09:31:53 +02:00
parent 130a901be7
commit fa5408bb03
3 changed files with 98 additions and 16 deletions

View file

@ -531,6 +531,30 @@ def rename_file():
}) })
# ── Vérification des conflits avant upload ───────────────────────────
@app.route("/check-upload", methods=["POST"])
def check_upload():
redir = _require_admin()
if redir:
return redir
subpath = request.form.get("path", "").strip()
names = request.form.getlist("names")
if subpath:
parts = Path(subpath).parts
if parts and (parts[0] in _HIDDEN or parts[0].startswith(".")):
abort(403)
dest = _safe_path(subpath) if subpath else ASSETS_ROOT
if not dest.is_dir():
abort(400)
conflicts = [n for n in names if n and (dest / secure_filename(n)).exists()]
return jsonify({"conflicts": conflicts})
# ── Upload de fichiers ──────────────────────────────────────────────── # ── Upload de fichiers ────────────────────────────────────────────────
@app.route("/upload", methods=["POST"]) @app.route("/upload", methods=["POST"])

View file

@ -165,7 +165,10 @@ main { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: fl
/* ── Upload ───────────────────────────────────────────────────────── */ /* ── Upload ───────────────────────────────────────────────────────── */
.upload-card h2 { margin-bottom: .8rem; } .upload-card h2 { margin-bottom: .8rem; }
.upload-conflict { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; margin-bottom: .8rem; }
.conflict-panel { margin-top: .9rem; padding: 1rem 1.2rem; background: #fffbeb; border: 1px solid #fcd34d; border-radius: var(--radius); display: flex; flex-direction: column; gap: .7rem; }
.conflict-title { font-size: .88rem; color: #92400e; }
.conflict-actions { display: flex; align-items: center; gap: .8rem; }
.drop-zone { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .5rem; border: 2px dashed var(--border); border-radius: var(--radius); padding: 2rem 1.5rem; cursor: pointer; background: var(--bg); transition: border-color .15s, background .15s; text-align: center; position: relative; margin-bottom: 1rem; } .drop-zone { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .5rem; border: 2px dashed var(--border); border-radius: var(--radius); padding: 2rem 1.5rem; cursor: pointer; background: var(--bg); transition: border-color .15s, background .15s; text-align: center; position: relative; margin-bottom: 1rem; }
.drop-zone:hover, .drop-zone:focus-within { border-color: var(--blue); background: var(--blue-light); } .drop-zone:hover, .drop-zone:focus-within { border-color: var(--blue); background: var(--blue-light); }
.drop-zone input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; } .drop-zone input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }

View file

@ -120,26 +120,81 @@
<span class="drop-names" id="drop-names"></span> <span class="drop-names" id="drop-names"></span>
<input type="file" name="files" id="upload-input" multiple> <input type="file" name="files" id="upload-input" multiple>
</label> </label>
<div class="upload-conflict"> <input type="hidden" name="conflict" id="conflict-value" value="overwrite">
<span class="resize-group-label">Si le fichier existe déjà</span>
<div class="resize-chips resize-chips--radio">
<label class="chip"><input type="radio" name="conflict" value="overwrite" checked><span>Écraser</span></label>
<label class="chip"><input type="radio" name="conflict" value="backup"><span>Backup</span></label>
<label class="chip"><input type="radio" name="conflict" value="rename"><span>Renommer</span></label>
<label class="chip"><input type="radio" name="conflict" value="skip"><span>Ignorer</span></label>
</div>
</div>
<button type="submit" class="btn btn-primary" id="upload-btn" disabled>Envoyer</button> <button type="submit" class="btn btn-primary" id="upload-btn" disabled>Envoyer</button>
<div id="upload-conflict-panel" class="conflict-panel" style="display:none">
<p class="conflict-title">⚠ Ces fichiers existent déjà : <strong id="conflict-list"></strong></p>
<div class="resize-chips resize-chips--radio">
<label class="chip"><input type="radio" name="conflict-choice" value="overwrite" checked><span>Écraser</span></label>
<label class="chip"><input type="radio" name="conflict-choice" value="backup"><span>Backup</span></label>
<label class="chip"><input type="radio" name="conflict-choice" value="rename"><span>Renommer</span></label>
<label class="chip"><input type="radio" name="conflict-choice" value="skip"><span>Ignorer</span></label>
</div>
<div class="conflict-actions">
<button type="button" id="conflict-confirm" class="btn btn-primary">Confirmer l'envoi</button>
<button type="button" id="conflict-cancel" class="btn-rename-cancel">Annuler</button>
</div>
</div>
</form> </form>
<script> <script>
(function () {
const form = document.getElementById('upload-form');
const inp = document.getElementById('upload-input'); const inp = document.getElementById('upload-input');
const btn = document.getElementById('upload-btn'); const btn = document.getElementById('upload-btn');
const lbl = document.getElementById('drop-names'); const lbl = document.getElementById('drop-names');
const panel = document.getElementById('upload-conflict-panel');
const hidden = document.getElementById('conflict-value');
const listEl = document.getElementById('conflict-list');
const confirm = document.getElementById('conflict-confirm');
const cancel = document.getElementById('conflict-cancel');
const PATH = {{ subpath | tojson }};
let resolved = false;
inp.addEventListener('change', () => { inp.addEventListener('change', () => {
const n = inp.files.length; const n = inp.files.length;
lbl.textContent = n ? Array.from(inp.files).map(f => f.name).join(', ') : ''; lbl.textContent = n ? Array.from(inp.files).map(f => f.name).join(', ') : '';
btn.disabled = n === 0; btn.disabled = n === 0;
panel.style.display = 'none';
resolved = false;
}); });
form.addEventListener('submit', async (e) => {
if (resolved) return;
e.preventDefault();
const names = Array.from(inp.files).map(f => f.name);
let conflicts = [];
try {
const fd = new FormData();
fd.append('path', PATH);
names.forEach(n => fd.append('names', n));
const resp = await fetch({{ url_for('check_upload') | tojson }}, { method: 'POST', body: fd });
const data = await resp.json();
conflicts = data.conflicts || [];
} catch (_) { /* réseau : on laisse passer */ }
if (!conflicts.length) {
resolved = true;
form.submit();
return;
}
listEl.textContent = conflicts.join(', ');
panel.style.display = 'block';
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
confirm.addEventListener('click', () => {
hidden.value = document.querySelector('input[name="conflict-choice"]:checked').value;
resolved = true;
form.submit();
});
cancel.addEventListener('click', () => {
panel.style.display = 'none';
});
})();
</script> </script>
</section> </section>