diff --git a/app/app.py b/app/app.py index 06a635f..3c24a6c 100644 --- a/app/app.py +++ b/app/app.py @@ -97,6 +97,20 @@ def _humansize(n: int) -> str: return f"{n:.1f} To" +def _backup_path(p: Path) -> Path: + ts = datetime.now().strftime("%Y%m%d%H%M%S") + return p.parent / f"{p.stem}_bak_{ts}{p.suffix}" + + +def _auto_rename(p: Path) -> Path: + i = 1 + while True: + candidate = p.parent / f"{p.stem}_{i}{p.suffix}" + if not candidate.exists(): + return candidate + i += 1 + + def _folder_stats(path: Path) -> dict: files, size = 0, 0 for f in path.rglob("*"): @@ -472,6 +486,51 @@ def delete_file(): return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse")) +# ── Renommage de fichiers ───────────────────────────────────────────── + +@app.route("/rename", methods=["POST"]) +def rename_file(): + redir = _require_admin() + if redir: + return redir + + subpath = request.form.get("path", "").strip() + new_name = request.form.get("new_name", "").strip() + + if not subpath or not new_name: + return jsonify({"error": "Paramètres manquants"}), 400 + + if "/" in new_name or "\\" in new_name or new_name in (".", ".."): + return jsonify({"error": "Nom invalide"}), 400 + + new_name = secure_filename(new_name) + if not new_name: + return jsonify({"error": "Nom invalide après nettoyage"}), 400 + + target = _safe_path(subpath) + if not target.is_file(): + return jsonify({"error": "Fichier introuvable"}), 404 + + top = Path(subpath).parts[0] + if top in _HIDDEN or top.startswith("."): + return jsonify({"error": "Accès refusé"}), 403 + + dest = target.parent / new_name + if not dest.is_relative_to(ASSETS_ROOT): + return jsonify({"error": "Destination invalide"}), 400 + + if dest.exists(): + return jsonify({"error": f"« {new_name} » existe déjà"}), 409 + + target.rename(dest) + new_path = str(dest.relative_to(ASSETS_ROOT)) + return jsonify({ + "name": new_name, + "path": new_path, + "browse_url": url_for("browse", subpath=new_path), + }) + + # ── Upload de fichiers ──────────────────────────────────────────────── @app.route("/upload", methods=["POST"]) @@ -540,6 +599,10 @@ def resize_image(): if not sizes or not formats: return jsonify({"error": "Tailles ou formats invalides"}), 400 + conflict = request.form.get("conflict", "skip") + if conflict not in ("backup", "overwrite", "rename", "skip"): + conflict = "skip" + is_svg = ext == ".svg" stem = target.stem parent = target.parent @@ -550,6 +613,24 @@ def resize_image(): out_name = f"{stem}_{size}x{size}.{fmt}" out_path = parent / out_name out_rel = str(out_path.relative_to(ASSETS_ROOT)) + + if out_path.exists(): + if conflict == "skip": + errors.append({"name": out_name, "reason": "Fichier existant, ignoré"}) + continue + elif conflict == "backup": + try: + bak = _backup_path(out_path) + out_path.rename(bak) + except Exception as exc: + errors.append({"name": out_name, "reason": f"Backup impossible : {exc}"}) + continue + elif conflict == "rename": + out_path = _auto_rename(out_path) + out_name = out_path.name + out_rel = str(out_path.relative_to(ASSETS_ROOT)) + # conflict == "overwrite" : on continue sans rien faire + try: if fmt == "svg" and is_svg: shutil.copy2(target, out_path) diff --git a/app/static/app.css b/app/static/app.css index 0e9b863..db1531c 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -135,16 +135,33 @@ main { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: fl .empty { text-align: center; color: var(--muted); padding: 2rem; font-style: italic; } -/* ── Vues / suppression (browse) ─────────────────────────────────────── */ -.col-hits { width: 4.5rem; text-align: right; white-space: nowrap; } -.col-del { width: 2.4rem; text-align: center; } +/* ── Vues / actions (browse) ─────────────────────────────────────────── */ +.col-hits { width: 4.5rem; text-align: right; white-space: nowrap; } +.col-actions { width: 5.5rem; text-align: right; } -.hits-badge { display: inline-block; font-size: .75rem; border-radius: 10px; padding: .1rem .45rem; font-weight: 600; } +.row-actions { display: flex; justify-content: flex-end; align-items: center; gap: .1rem; } + +.hits-badge { display: inline-block; font-size: .75rem; border-radius: 10px; padding: .1rem .45rem; font-weight: 600; } .hits-active { background: #dcfce7; color: #15803d; } .hits-zero { background: #f3f4f6; color: #9ca3af; } -.btn-del { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .4; padding: .2rem; line-height: 1; transition: opacity .15s; } +.btn-del { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .4; padding: .2rem; line-height: 1; transition: opacity .15s; } .btn-del:hover { opacity: 1; } +.btn-rename { background: none; border: none; cursor: pointer; font-size: .85rem; opacity: .35; padding: .2rem; line-height: 1; transition: opacity .15s; } +.btn-rename:hover { opacity: 1; } + +/* ── Rename inline ───────────────────────────────────────────────────── */ +.btn-icon { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .4; padding: .15rem; line-height: 1; transition: opacity .15s; } +.btn-icon:hover { opacity: 1; } + +.rename-inline { display: flex; align-items: center; gap: .4rem; padding: .4rem 0; flex-wrap: wrap; } +.rename-input { border: 1px solid var(--blue); border-radius: 5px; padding: .3rem .6rem; font-size: .88rem; outline: none; min-width: 200px; } +.rename-input:focus { box-shadow: 0 0 0 2px rgba(26,107,191,.18); } +.btn-rename-ok { background: none; border: none; cursor: pointer; color: #15803d; font-size: 1rem; padding: .2rem .4rem; border-radius: 4px; } +.btn-rename-ok:hover { background: #dcfce7; } +.btn-rename-cancel { background: none; border: none; cursor: pointer; color: #b91c1c; font-size: 1rem; padding: .2rem .4rem; border-radius: 4px; } +.btn-rename-cancel:hover { background: #fef2f2; } +.rename-error { font-size: .8rem; color: #b91c1c; font-style: italic; } /* ── Upload ───────────────────────────────────────────────────────── */ .upload-card h2 { margin-bottom: .8rem; } @@ -175,6 +192,11 @@ main { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: fl .chip--disabled { cursor: not-allowed; opacity: .45; } .chip--disabled span { background: #f3f4f6; color: #9ca3af; } +.resize-chips--radio { gap: 0; } +.resize-chips--radio .chip span { border-radius: 0; border-right-width: 0; } +.resize-chips--radio .chip:first-child span { border-radius: 20px 0 0 20px; } +.resize-chips--radio .chip:last-child span { border-radius: 0 20px 20px 0; border-right-width: 2px; } + .resize-actions { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; } .resize-hint { font-size: .82rem; color: var(--muted); font-style: italic; } diff --git a/app/templates/browse.html b/app/templates/browse.html index 48ad1b9..f30888f 100644 --- a/app/templates/browse.html +++ b/app/templates/browse.html @@ -26,7 +26,7 @@