feat: corbeille avec purge automatique 30 jours
- Suppression déplace dans .trash/ (arborescence préservée + .trashinfo) - /trash : liste, restauration (conflit overwrite/rename), suppression définitive, vidage complet - Purge automatique des fichiers > 30 jours à chaque visite /trash - Badge rouge dans la nav avec le nombre de fichiers en corbeille - Extraction du tableau de fichiers en partial _file_table.html partagé entre browse et trash Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dd83e6f36e
commit
b3af420d36
6 changed files with 427 additions and 88 deletions
191
app/app.py
191
app/app.py
|
|
@ -6,7 +6,7 @@ import shutil
|
|||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from urllib.parse import urlencode
|
||||
from PIL import Image
|
||||
|
|
@ -33,6 +33,7 @@ oauth.register(
|
|||
ADMIN_GROUPS = set(os.environ.get("ADMIN_GROUPS", "admins").split(","))
|
||||
ADMIN_EMAILS = set(e.strip() for e in os.environ.get("ADMIN_EMAILS", "").split(",") if e.strip())
|
||||
ASSETS_ROOT = Path(os.environ.get("ASSETS_ROOT", ".")).resolve()
|
||||
TRASH_ROOT = ASSETS_ROOT / ".trash"
|
||||
|
||||
_alpid_base = os.environ["ALPID_DISCOVERY_URL"].split("/.well-known/")[0]
|
||||
ALPID_LOGOUT_URL = _alpid_base + "/protocol/openid-connect/logout"
|
||||
|
|
@ -172,6 +173,112 @@ def _auto_rename(p: Path) -> Path:
|
|||
i += 1
|
||||
|
||||
|
||||
def _trash_move(target: Path, subpath: str) -> None:
|
||||
rel = Path(subpath)
|
||||
trash_dest = TRASH_ROOT / rel
|
||||
trash_dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if trash_dest.exists():
|
||||
ts = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
trash_dest = trash_dest.parent / f"{trash_dest.stem}_{ts}{trash_dest.suffix}"
|
||||
shutil.move(str(target), trash_dest)
|
||||
Path(str(trash_dest) + ".trashinfo").write_text(json.dumps({
|
||||
"original_path": str(rel),
|
||||
"deleted_at": datetime.now().isoformat(timespec="seconds"),
|
||||
}))
|
||||
|
||||
|
||||
def _trash_restore(trash_rel: str, conflict: str = "") -> tuple:
|
||||
target = (TRASH_ROOT / trash_rel).resolve()
|
||||
if not str(target).startswith(str(TRASH_ROOT)):
|
||||
return False, "forbidden"
|
||||
if not target.is_file():
|
||||
return False, "not_found"
|
||||
info_path = Path(str(target) + ".trashinfo")
|
||||
try:
|
||||
original = json.loads(info_path.read_text())["original_path"]
|
||||
except Exception:
|
||||
original = trash_rel
|
||||
dest = (ASSETS_ROOT / original).resolve()
|
||||
if not str(dest).startswith(str(ASSETS_ROOT)):
|
||||
return False, "forbidden"
|
||||
if dest.exists():
|
||||
if not conflict:
|
||||
return False, "conflict"
|
||||
if conflict == "rename":
|
||||
dest = _auto_rename(dest)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(target), str(dest))
|
||||
if info_path.exists():
|
||||
info_path.unlink()
|
||||
return True, str(dest.relative_to(ASSETS_ROOT))
|
||||
|
||||
|
||||
def _trash_purge(days: int = 30) -> int:
|
||||
if not TRASH_ROOT.exists():
|
||||
return 0
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
count = 0
|
||||
for info_file in list(TRASH_ROOT.rglob("*.trashinfo")):
|
||||
try:
|
||||
deleted_at = datetime.fromisoformat(
|
||||
json.loads(info_file.read_text())["deleted_at"]
|
||||
)
|
||||
if deleted_at < cutoff:
|
||||
f = Path(str(info_file)[:-len(".trashinfo")])
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
info_file.unlink()
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
for d in sorted(TRASH_ROOT.rglob("*"), reverse=True):
|
||||
if d.is_dir() and d != TRASH_ROOT:
|
||||
try:
|
||||
d.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return count
|
||||
|
||||
|
||||
def _trash_list() -> list:
|
||||
if not TRASH_ROOT.exists():
|
||||
return []
|
||||
entries = []
|
||||
for info_file in TRASH_ROOT.rglob("*.trashinfo"):
|
||||
try:
|
||||
data = json.loads(info_file.read_text())
|
||||
fpath = Path(str(info_file)[:-len(".trashinfo")])
|
||||
if not fpath.exists():
|
||||
info_file.unlink()
|
||||
continue
|
||||
rel = fpath.relative_to(TRASH_ROOT)
|
||||
ext = fpath.suffix.lower()
|
||||
stat = fpath.stat()
|
||||
entries.append({
|
||||
"name": fpath.name,
|
||||
"path": str(rel),
|
||||
"original_path": data.get("original_path", str(rel)),
|
||||
"deleted_at": datetime.fromisoformat(data["deleted_at"]),
|
||||
"size": stat.st_size,
|
||||
"is_dir": False,
|
||||
"is_image": ext in IMAGE_EXT,
|
||||
"is_text": ext in TEXT_EXT,
|
||||
"is_pdf": ext in PDF_EXT,
|
||||
"ext": ext,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
entries.sort(key=lambda e: e["deleted_at"], reverse=True)
|
||||
return entries
|
||||
|
||||
|
||||
def _trash_count() -> int:
|
||||
if not TRASH_ROOT.exists():
|
||||
return 0
|
||||
return sum(1 for f in TRASH_ROOT.rglob("*")
|
||||
if f.is_file() and not f.name.endswith(".trashinfo"))
|
||||
|
||||
|
||||
def _folder_stats(path: Path) -> dict:
|
||||
files, size = 0, 0
|
||||
for f in path.rglob("*"):
|
||||
|
|
@ -226,6 +333,13 @@ def _entry(item: Path) -> dict:
|
|||
}
|
||||
|
||||
|
||||
# ── Context processor ────────────────────────────────────────────────
|
||||
|
||||
@app.context_processor
|
||||
def _inject_globals():
|
||||
return {"trash_count": _trash_count() if _user() else 0}
|
||||
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/auth/login")
|
||||
|
|
@ -544,10 +658,83 @@ def delete_file():
|
|||
if top in _HIDDEN or top.startswith("."):
|
||||
abort(403)
|
||||
parent = str(Path(subpath).parent)
|
||||
target.unlink()
|
||||
_trash_move(target, subpath)
|
||||
return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse"))
|
||||
|
||||
|
||||
# ── Corbeille ────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/trash")
|
||||
def trash_list():
|
||||
redir = _require_admin()
|
||||
if redir:
|
||||
return redir
|
||||
_trash_purge(30)
|
||||
entries = _trash_list()
|
||||
return render_template("trash.html", entries=entries, humansize=_humansize)
|
||||
|
||||
|
||||
@app.route("/trash/raw/<path:subpath>")
|
||||
def trash_raw(subpath):
|
||||
redir = _require_admin()
|
||||
if redir:
|
||||
return redir
|
||||
target = (TRASH_ROOT / subpath).resolve()
|
||||
if not str(target).startswith(str(TRASH_ROOT)):
|
||||
abort(400)
|
||||
if not target.is_file():
|
||||
abort(404)
|
||||
return send_from_directory(TRASH_ROOT, subpath)
|
||||
|
||||
|
||||
@app.route("/trash/delete", methods=["POST"])
|
||||
def trash_delete():
|
||||
redir = _require_admin()
|
||||
if redir:
|
||||
return redir
|
||||
trash_rel = request.form.get("path", "").strip()
|
||||
if not trash_rel:
|
||||
abort(400)
|
||||
target = (TRASH_ROOT / trash_rel).resolve()
|
||||
if not str(target).startswith(str(TRASH_ROOT)):
|
||||
abort(400)
|
||||
if target.is_file():
|
||||
target.unlink()
|
||||
info = Path(str(target) + ".trashinfo")
|
||||
if info.exists():
|
||||
info.unlink()
|
||||
return redirect(url_for("trash_list"))
|
||||
|
||||
|
||||
@app.route("/trash/restore", methods=["POST"])
|
||||
def trash_restore():
|
||||
redir = _require_admin()
|
||||
if redir:
|
||||
return redir
|
||||
trash_rel = request.form.get("path", "").strip()
|
||||
conflict = request.form.get("conflict", "").strip()
|
||||
if not trash_rel:
|
||||
abort(400)
|
||||
ok, result = _trash_restore(trash_rel, conflict)
|
||||
if not ok:
|
||||
if result == "conflict":
|
||||
return jsonify({"conflict": True, "path": trash_rel}), 409
|
||||
abort(400)
|
||||
parent = str(Path(result).parent)
|
||||
return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse"))
|
||||
|
||||
|
||||
@app.route("/trash/empty", methods=["POST"])
|
||||
def trash_empty():
|
||||
redir = _require_admin()
|
||||
if redir:
|
||||
return redir
|
||||
if TRASH_ROOT.exists():
|
||||
shutil.rmtree(TRASH_ROOT)
|
||||
TRASH_ROOT.mkdir(exist_ok=True)
|
||||
return redirect(url_for("trash_list"))
|
||||
|
||||
|
||||
# ── Renommage de fichiers ─────────────────────────────────────────────
|
||||
|
||||
@app.route("/rename", methods=["POST"])
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ header { background: var(--blue-dark); color: #fff; }
|
|||
.header-nav a { color: rgba(255,255,255,.8); padding: .4rem .8rem; border-radius: 6px; font-size: .9rem; transition: background .15s; }
|
||||
.header-nav a:hover { background: rgba(255,255,255,.12); color: #fff; text-decoration: none; }
|
||||
.header-nav a.active { background: rgba(255,255,255,.18); color: #fff; font-weight: 600; }
|
||||
.nav-trash { position: relative; }
|
||||
.trash-badge { display: inline-flex; align-items: center; justify-content: center;
|
||||
background: #ef4444; color: #fff; border-radius: 9px; font-size: .68rem;
|
||||
font-weight: 700; min-width: 1.1rem; height: 1.1rem; padding: 0 .3rem;
|
||||
margin-left: .3rem; vertical-align: middle; line-height: 1; }
|
||||
|
||||
.header-user { margin-left: auto; display: flex; align-items: center; gap: 1rem; color: rgba(255,255,255,.85); font-size: .9rem; white-space: nowrap; }
|
||||
.btn-logout { color: rgba(255,255,255,.75); font-size: .85rem; border: 1px solid rgba(255,255,255,.4); border-radius: 4px; padding: .3rem .7rem; }
|
||||
|
|
@ -242,3 +247,16 @@ main { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: fl
|
|||
.col-date { display: none; }
|
||||
.stat-box .value { font-size: 1.4rem; }
|
||||
}
|
||||
|
||||
/* ── Corbeille ──────────────────────────────────────────────────────── */
|
||||
.trash-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .75rem; margin-bottom: .5rem; }
|
||||
.trash-header h2 { margin: 0; font-size: 1.1rem; }
|
||||
.trash-count-label { font-weight: 400; color: var(--muted); }
|
||||
.trash-info { font-size: .82rem; color: var(--muted); font-style: italic; margin-bottom: 1rem; }
|
||||
.trash-filename { font-weight: 500; }
|
||||
.col-origin { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .82rem; color: var(--muted); }
|
||||
.btn-restore { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .5; padding: .2rem; line-height: 1; transition: opacity .15s; }
|
||||
.btn-restore:hover { opacity: 1; }
|
||||
.btn-del--perm { color: #b91c1c; }
|
||||
.btn-danger { background: #b91c1c; color: #fff; border: none; border-radius: 6px; padding: .45rem .9rem; font-size: .88rem; cursor: pointer; font-weight: 600; transition: background .15s; }
|
||||
.btn-danger:hover { background: #991b1b; }
|
||||
|
|
|
|||
124
app/templates/_file_table.html
Normal file
124
app/templates/_file_table.html
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
{#
|
||||
Partial : tableau de fichiers.
|
||||
Paramètres de contexte :
|
||||
entries – liste de dicts (champs _entry ou _trash_list)
|
||||
mode – "browse" | "trash"
|
||||
has_hits – booléen (browse uniquement)
|
||||
humansize – filtre taille
|
||||
subpath – chemin courant (browse uniquement, pour la ligne "..")
|
||||
breadcrumb – liste de crumbs (browse uniquement)
|
||||
#}
|
||||
<table class="file-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-icon"></th>
|
||||
<th>Nom</th>
|
||||
{% if mode == 'trash' %}
|
||||
<th class="col-origin">Emplacement d'origine</th>
|
||||
<th class="col-date">Supprimé le</th>
|
||||
<th class="col-size">Taille</th>
|
||||
{% else %}
|
||||
<th class="col-size">Taille</th>
|
||||
<th class="col-date">Modifié le</th>
|
||||
{% if has_hits %}<th class="col-hits">Vues</th>{% endif %}
|
||||
{% endif %}
|
||||
<th class="col-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
{% if mode == 'browse' and subpath %}
|
||||
<tr>
|
||||
<td class="col-icon">⬆</td>
|
||||
<td>
|
||||
{% if breadcrumb | length > 1 %}
|
||||
<a href="{{ url_for('browse', subpath=breadcrumb[-2].path) }}">.. (dossier parent)</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('browse') }}">.. (racine)</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td></td><td></td>
|
||||
{% if has_hits %}<td></td>{% endif %}
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td class="col-icon">
|
||||
{%- if e.is_dir -%}📁
|
||||
{%- elif e.is_image -%}🖼
|
||||
{%- elif e.is_pdf -%}📕
|
||||
{%- elif e.is_text -%}📄
|
||||
{%- else -%}📎
|
||||
{%- endif -%}
|
||||
</td>
|
||||
<td>
|
||||
<div class="col-name">
|
||||
{% if e.is_image %}
|
||||
<img class="thumb-sm"
|
||||
src="{{ url_for('trash_raw' if mode == 'trash' else 'raw_file', subpath=e.path) }}"
|
||||
alt="{{ e.name }}" loading="lazy">
|
||||
{% endif %}
|
||||
{% if mode == 'trash' %}
|
||||
<span class="trash-filename">{{ e.name }}</span>
|
||||
{% else %}
|
||||
<a href="{{ url_for('browse', subpath=e.path) }}">
|
||||
{{ e.name }}{% if e.is_dir %}/{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if e.ext and not e.is_dir %}
|
||||
<span class="type-badge">{{ e.ext }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{% if mode == 'trash' %}
|
||||
<td class="col-origin" title="{{ e.original_path }}">{{ e.original_path }}</td>
|
||||
<td class="col-date">{{ e.deleted_at.strftime('%d/%m/%Y %H:%M') }}</td>
|
||||
<td class="col-size">{{ humansize(e.size) }}</td>
|
||||
{% else %}
|
||||
<td class="col-size">{{ humansize(e.size) if e.size is not none else '—' }}</td>
|
||||
<td class="col-date">{{ e.mtime.strftime('%d/%m/%Y %H:%M') }}</td>
|
||||
{% if has_hits %}
|
||||
<td class="col-hits">
|
||||
{% if not e.is_dir %}
|
||||
{% if e.hits %}
|
||||
<span class="hits-badge hits-active" title="{{ e.hits }} requête(s) dans les stats">{{ e.hits }}</span>
|
||||
{% else %}
|
||||
<span class="hits-badge hits-zero" title="Aucune vue">0</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<td class="col-actions">
|
||||
{% if mode == 'browse' and not e.is_dir %}
|
||||
<div class="row-actions">
|
||||
<button type="button" class="btn-rename"
|
||||
data-path="{{ e.path }}" data-name="{{ e.name }}"
|
||||
title="Renommer">✏️</button>
|
||||
<form method="post" action="{{ url_for('delete_file') }}" style="display:contents">
|
||||
<input type="hidden" name="path" value="{{ e.path }}">
|
||||
<button type="submit" class="btn-del" title="Mettre à la corbeille">🗑</button>
|
||||
</form>
|
||||
</div>
|
||||
{% elif mode == 'trash' %}
|
||||
<div class="row-actions">
|
||||
<button type="button" class="btn-restore"
|
||||
data-path="{{ e.path }}" title="Restaurer">♻️</button>
|
||||
<form method="post" action="{{ url_for('trash_delete') }}" style="display:contents">
|
||||
<input type="hidden" name="path" value="{{ e.path }}">
|
||||
<button type="submit" class="btn-del btn-del--perm"
|
||||
title="Supprimer définitivement"
|
||||
onclick="return confirm('Supprimer définitivement {{ e.name|replace("'","\\'")|replace('"','"') }} ?')">🗑</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -22,6 +22,8 @@
|
|||
{% if request.endpoint == 'browse' %}class="active"{% endif %}>Parcourir</a>
|
||||
<a href="{{ url_for('stats') }}"
|
||||
{% if request.endpoint in ('stats', 'stats_report') %}class="active"{% endif %}>Statistiques</a>
|
||||
<a href="{{ url_for('trash_list') }}"
|
||||
class="nav-trash{% if request.endpoint == 'trash_list' %} active{% endif %}">Corbeille{% if trash_count %}<span class="trash-badge">{{ trash_count }}</span>{% endif %}</a>
|
||||
</nav>
|
||||
<form class="header-search" action="{{ url_for('search') }}" method="get" role="search">
|
||||
<input type="search" name="q" placeholder="Rechercher…"
|
||||
|
|
|
|||
|
|
@ -18,92 +18,8 @@
|
|||
</nav>
|
||||
|
||||
{% if entries or subpath %}
|
||||
<table class="file-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-icon"></th>
|
||||
<th>Nom</th>
|
||||
<th class="col-size">Taille</th>
|
||||
<th class="col-date">Modifié le</th>
|
||||
{% if has_hits %}<th class="col-hits">Vues</th>{% endif %}
|
||||
<th class="col-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
{% if subpath %}
|
||||
<tr>
|
||||
<td class="col-icon">⬆</td>
|
||||
<td>
|
||||
{% if breadcrumb | length > 1 %}
|
||||
<a href="{{ url_for('browse', subpath=breadcrumb[-2].path) }}">.. (dossier parent)</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('browse') }}">.. (racine)</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td></td><td></td>
|
||||
{% if has_hits %}<td></td>{% endif %}
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td class="col-icon">
|
||||
{%- if e.is_dir -%}📁
|
||||
{%- elif e.is_image -%}🖼
|
||||
{%- elif e.is_pdf -%}📕
|
||||
{%- elif e.is_text -%}📄
|
||||
{%- else -%}📎
|
||||
{%- endif -%}
|
||||
</td>
|
||||
<td>
|
||||
<div class="col-name">
|
||||
{% if e.is_image %}
|
||||
<img class="thumb-sm"
|
||||
src="{{ url_for('raw_file', subpath=e.path) }}"
|
||||
alt="{{ e.name }}"
|
||||
loading="lazy">
|
||||
{% endif %}
|
||||
<a href="{{ url_for('browse', subpath=e.path) }}">
|
||||
{{ e.name }}{% if e.is_dir %}/{% endif %}
|
||||
</a>
|
||||
{% if e.ext and not e.is_dir %}
|
||||
<span class="type-badge">{{ e.ext }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-size">{{ humansize(e.size) if e.size is not none else '—' }}</td>
|
||||
<td class="col-date">{{ e.mtime.strftime('%d/%m/%Y %H:%M') }}</td>
|
||||
{% if has_hits %}
|
||||
<td class="col-hits">
|
||||
{% if not e.is_dir %}
|
||||
{% if e.hits %}
|
||||
<span class="hits-badge hits-active" title="{{ e.hits }} requête(s) dans les stats">{{ e.hits }}</span>
|
||||
{% else %}
|
||||
<span class="hits-badge hits-zero" title="Aucune vue dans les stats">0</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="col-actions">
|
||||
{% if not e.is_dir %}
|
||||
<div class="row-actions">
|
||||
<button type="button" class="btn-rename"
|
||||
data-path="{{ e.path }}" data-name="{{ e.name }}"
|
||||
title="Renommer">✏️</button>
|
||||
<form method="post" action="{{ url_for('delete_file') }}" style="display:contents">
|
||||
<input type="hidden" name="path" value="{{ e.path }}">
|
||||
<button type="submit" class="btn-del" title="Supprimer du CDN">🗑</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
{% set mode = 'browse' %}
|
||||
{% include '_file_table.html' %}
|
||||
{% else %}
|
||||
<p class="empty">Dossier vide.</p>
|
||||
{% endif %}
|
||||
|
|
|
|||
92
app/templates/trash.html
Normal file
92
app/templates/trash.html
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Corbeille{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<section class="card">
|
||||
<div class="trash-header">
|
||||
<h2>Corbeille
|
||||
{% if entries %}
|
||||
<span class="trash-count-label">— {{ entries|length }} fichier{{ 's' if entries|length > 1 else '' }}</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% if entries %}
|
||||
<form method="post" action="{{ url_for('trash_empty') }}"
|
||||
onsubmit="return confirm('Vider définitivement toute la corbeille ?')">
|
||||
<button type="submit" class="btn btn-danger">Vider la corbeille</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="trash-info">Les fichiers sont supprimés définitivement après 30 jours.</p>
|
||||
|
||||
{% if entries %}
|
||||
{% set mode = 'trash' %}
|
||||
{% include '_file_table.html' %}
|
||||
{% else %}
|
||||
<p class="empty">La corbeille est vide.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div id="restore-conflict-panel" class="conflict-panel" style="display:none">
|
||||
<p class="conflict-title">⚠ Un fichier existe déjà à cet emplacement : <strong id="restore-conflict-name"></strong></p>
|
||||
<div class="resize-chips resize-chips--radio">
|
||||
<label class="chip"><input type="radio" name="restore-conflict" value="overwrite" checked><span>Écraser</span></label>
|
||||
<label class="chip"><input type="radio" name="restore-conflict" value="rename"><span>Renommer</span></label>
|
||||
</div>
|
||||
<div class="conflict-actions">
|
||||
<button type="button" id="restore-confirm" class="btn btn-primary">Confirmer la restauration</button>
|
||||
<button type="button" id="restore-cancel" class="btn-rename-cancel">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const RESTORE_URL = {{ url_for('trash_restore') | tojson }};
|
||||
const conflictPanel = document.getElementById('restore-conflict-panel');
|
||||
const conflictName = document.getElementById('restore-conflict-name');
|
||||
const confirmBtn = document.getElementById('restore-confirm');
|
||||
const cancelBtn = document.getElementById('restore-cancel');
|
||||
let pendingPath = null;
|
||||
|
||||
async function doRestore(path, conflict) {
|
||||
const fd = new FormData();
|
||||
fd.append('path', path);
|
||||
if (conflict) fd.append('conflict', conflict);
|
||||
try {
|
||||
const resp = await fetch(RESTORE_URL, { method: 'POST', body: fd });
|
||||
if (resp.status === 409) {
|
||||
pendingPath = path;
|
||||
conflictName.textContent = path.split('/').pop();
|
||||
conflictPanel.style.display = 'block';
|
||||
conflictPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
return;
|
||||
}
|
||||
if (resp.redirected) { window.location.href = resp.url; return; }
|
||||
if (resp.ok) { window.location.href = resp.url || window.location.href; return; }
|
||||
alert('Erreur lors de la restauration.');
|
||||
} catch (_) {
|
||||
alert('Erreur réseau.');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.btn-restore');
|
||||
if (!btn) return;
|
||||
doRestore(btn.dataset.path, '');
|
||||
});
|
||||
|
||||
confirmBtn.addEventListener('click', () => {
|
||||
const strategy = document.querySelector('input[name="restore-conflict"]:checked')?.value || 'overwrite';
|
||||
conflictPanel.style.display = 'none';
|
||||
if (pendingPath) doRestore(pendingPath, strategy);
|
||||
pendingPath = null;
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
conflictPanel.style.display = 'none';
|
||||
pendingPath = null;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
Loading…
Reference in a new issue