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:
parent
130a901be7
commit
fa5408bb03
3 changed files with 98 additions and 16 deletions
24
app/app.py
24
app/app.py
|
|
@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@app.route("/upload", methods=["POST"])
|
||||
|
|
|
|||
|
|
@ -165,7 +165,10 @@ main { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: fl
|
|||
|
||||
/* ── Upload ───────────────────────────────────────────────────────── */
|
||||
.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: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%; }
|
||||
|
|
|
|||
|
|
@ -120,26 +120,81 @@
|
|||
<span class="drop-names" id="drop-names"></span>
|
||||
<input type="file" name="files" id="upload-input" multiple>
|
||||
</label>
|
||||
<div class="upload-conflict">
|
||||
<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>
|
||||
<input type="hidden" name="conflict" id="conflict-value" value="overwrite">
|
||||
<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>
|
||||
<script>
|
||||
(function () {
|
||||
const form = document.getElementById('upload-form');
|
||||
const inp = document.getElementById('upload-input');
|
||||
const btn = document.getElementById('upload-btn');
|
||||
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', () => {
|
||||
const n = inp.files.length;
|
||||
lbl.textContent = n ? Array.from(inp.files).map(f => f.name).join(', ') : '';
|
||||
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>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue