feat: conflit resize à la demande + nommage sans suffixe dimensions

- Route POST /check-resize : pre-check des fichiers cibles avant génération
  (utilise déjà le strip _NxN sur le stem)
- Route /resize : strip du suffixe _NxN dans le stem source
  (logo_1024x1024.png → 500x500 = logo_500x500.png)
- preview_image.html : bloc conflit masqué par défaut
  Au clic Générer → pre-check AJAX → si conflit : panneau jaune identique
  à l'upload avec Backup/Écraser/Renommer/Ignorer puis Confirmer
  Sans conflit → génération directe sans interruption

Ferme #10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alpinux 2026-05-06 09:54:39 +02:00
parent 31ddff2a75
commit 8f6aa292ef
2 changed files with 102 additions and 32 deletions

View file

@ -1,6 +1,7 @@
import io import io
import json import json
import os import os
import re
import shutil import shutil
import subprocess import subprocess
import threading import threading
@ -663,6 +664,44 @@ def upload_file():
return redirect(url_for("browse", subpath=subpath) if subpath else url_for("browse")) return redirect(url_for("browse", subpath=subpath) if subpath else url_for("browse"))
# ── Vérification des conflits avant redimensionnement ────────────────
@app.route("/check-resize", methods=["POST"])
def check_resize():
redir = _require_admin()
if redir:
return redir
subpath = request.form.get("path", "").strip()
raw_sizes = request.form.getlist("sizes")
raw_formats = request.form.getlist("formats")
if not subpath:
return jsonify({"conflicts": []})
target = _safe_path(subpath)
if not target.is_file():
return jsonify({"conflicts": []})
try:
sizes = [int(s) for s in raw_sizes if int(s) in RESIZE_SIZES]
except (ValueError, TypeError):
return jsonify({"conflicts": []})
formats = [f for f in raw_formats if f in RESIZE_FORMATS]
stem = re.sub(r"_\d+x\d+$", "", target.stem)
parent = target.parent
conflicts = []
for fmt in formats:
for size in sizes:
out_name = f"{stem}_{size}x{size}.{fmt}"
if (parent / out_name).exists():
conflicts.append(out_name)
return jsonify({"conflicts": conflicts})
# ── Redimensionnement d'images ──────────────────────────────────────── # ── Redimensionnement d'images ────────────────────────────────────────
@app.route("/resize", methods=["POST"]) @app.route("/resize", methods=["POST"])
@ -704,7 +743,7 @@ def resize_image():
conflict = "skip" conflict = "skip"
is_svg = ext == ".svg" is_svg = ext == ".svg"
stem = target.stem stem = re.sub(r"_\d+x\d+$", "", target.stem)
parent = target.parent parent = target.parent
created, errors = [], [] created, errors = [], []

View file

@ -122,33 +122,25 @@
</div> </div>
</div> </div>
<div class="resize-group">
<div class="resize-group-label">Si le fichier existe déjà</div>
<div class="resize-chips resize-chips--radio">
<label class="chip">
<input type="radio" name="conflict" value="backup" checked>
<span>Backup</span>
</label>
<label class="chip">
<input type="radio" name="conflict" value="overwrite">
<span>Écraser</span>
</label>
<label class="chip">
<input type="radio" name="conflict" value="rename">
<span>Renommer la copie</span>
</label>
<label class="chip">
<input type="radio" name="conflict" value="skip">
<span>Ignorer</span>
</label>
</div>
</div>
<div class="resize-actions"> <div class="resize-actions">
<button id="resize-btn" class="btn btn-primary" disabled>Générer les copies</button> <button id="resize-btn" class="btn btn-primary" disabled>Générer les copies</button>
<span class="resize-hint">Les fichiers sont créés dans le même dossier</span> <span class="resize-hint">Les fichiers sont créés dans le même dossier</span>
</div> </div>
<div id="resize-conflict-panel" class="conflict-panel" style="display:none">
<p class="conflict-title">⚠ Ces fichiers existent déjà : <strong id="resize-conflict-list"></strong></p>
<div class="resize-chips resize-chips--radio">
<label class="chip"><input type="radio" name="resize-conflict" value="backup" checked><span>Backup</span></label>
<label class="chip"><input type="radio" name="resize-conflict" value="overwrite"><span>Écraser</span></label>
<label class="chip"><input type="radio" name="resize-conflict" value="rename"><span>Renommer</span></label>
<label class="chip"><input type="radio" name="resize-conflict" value="skip"><span>Ignorer</span></label>
</div>
<div class="conflict-actions">
<button type="button" id="resize-confirm" class="btn btn-primary">Confirmer la génération</button>
<button type="button" id="resize-cancel-conflict" class="btn-rename-cancel">Annuler</button>
</div>
</div>
<div id="resize-result" class="resize-result" style="display:none"></div> <div id="resize-result" class="resize-result" style="display:none"></div>
</div> </div>
@ -209,7 +201,13 @@
const fmtCbs = document.querySelectorAll('.resize-fmt'); const fmtCbs = document.querySelectorAll('.resize-fmt');
const btn = document.getElementById('resize-btn'); const btn = document.getElementById('resize-btn');
const result = document.getElementById('resize-result'); const result = document.getElementById('resize-result');
const conflictPanel = document.getElementById('resize-conflict-panel');
const conflictList = document.getElementById('resize-conflict-list');
const confirmBtn = document.getElementById('resize-confirm');
const cancelConflict= document.getElementById('resize-cancel-conflict');
const RESIZE_URL = {{ url_for('resize_image') | tojson }}; const RESIZE_URL = {{ url_for('resize_image') | tojson }};
const CHECK_RESIZE_URL= {{ url_for('check_resize') | tojson }};
let resizeResolved = false;
function canSubmit() { function canSubmit() {
return Array.from(szCbs).some(c => c.checked) return Array.from(szCbs).some(c => c.checked)
@ -217,10 +215,11 @@
} }
[...szCbs, ...fmtCbs].forEach(c => c.addEventListener('change', () => { [...szCbs, ...fmtCbs].forEach(c => c.addEventListener('change', () => {
btn.disabled = !canSubmit(); btn.disabled = !canSubmit();
conflictPanel.style.display = 'none';
resizeResolved = false;
})); }));
btn.addEventListener('click', async () => { async function doResize(conflict) {
const conflict = document.querySelector('input[name="conflict"]:checked')?.value || 'skip';
const fd = new FormData(); const fd = new FormData();
fd.append('path', FILE_PATH); fd.append('path', FILE_PATH);
fd.append('conflict', conflict); fd.append('conflict', conflict);
@ -235,7 +234,6 @@
const resp = await fetch(RESIZE_URL, { method: 'POST', body: fd }); const resp = await fetch(RESIZE_URL, { method: 'POST', body: fd });
const data = await resp.json(); const data = await resp.json();
let html = ''; let html = '';
if (data.created && data.created.length) { if (data.created && data.created.length) {
html += '<p class="resize-ok-title">✓ ' + data.created.length + ' fichier(s) créé(s)</p>'; html += '<p class="resize-ok-title">✓ ' + data.created.length + ' fichier(s) créé(s)</p>';
html += '<ul class="resize-list">'; html += '<ul class="resize-list">';
@ -257,10 +255,43 @@
} catch (_) { } catch (_) {
result.innerHTML = '<p class="resize-err-title">Erreur réseau.</p>'; result.innerHTML = '<p class="resize-err-title">Erreur réseau.</p>';
} }
result.style.display = 'block'; result.style.display = 'block';
btn.textContent = 'Générer les copies'; btn.textContent = 'Générer les copies';
btn.disabled = !canSubmit(); btn.disabled = !canSubmit();
resizeResolved = false;
}
btn.addEventListener('click', async () => {
const fd = new FormData();
fd.append('path', FILE_PATH);
szCbs.forEach(c => { if (c.checked) fd.append('sizes', c.value); });
fmtCbs.forEach(c => { if (c.checked) fd.append('formats', c.value); });
let conflicts = [];
try {
const resp = await fetch(CHECK_RESIZE_URL, { method: 'POST', body: fd });
const data = await resp.json();
conflicts = data.conflicts || [];
} catch (_) { /* réseau : on génère directement */ }
if (!conflicts.length) {
doResize('overwrite');
return;
}
conflictList.textContent = conflicts.join(', ');
conflictPanel.style.display = 'block';
conflictPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
confirmBtn.addEventListener('click', () => {
const strategy = document.querySelector('input[name="resize-conflict"]:checked')?.value || 'skip';
conflictPanel.style.display = 'none';
doResize(strategy);
});
cancelConflict.addEventListener('click', () => {
conflictPanel.style.display = 'none';
}); });
})(); })();
</script> </script>