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 subprocess
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
@ -33,6 +33,7 @@ oauth.register(
|
||||||
ADMIN_GROUPS = set(os.environ.get("ADMIN_GROUPS", "admins").split(","))
|
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())
|
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()
|
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_base = os.environ["ALPID_DISCOVERY_URL"].split("/.well-known/")[0]
|
||||||
ALPID_LOGOUT_URL = _alpid_base + "/protocol/openid-connect/logout"
|
ALPID_LOGOUT_URL = _alpid_base + "/protocol/openid-connect/logout"
|
||||||
|
|
@ -172,6 +173,112 @@ def _auto_rename(p: Path) -> Path:
|
||||||
i += 1
|
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:
|
def _folder_stats(path: Path) -> dict:
|
||||||
files, size = 0, 0
|
files, size = 0, 0
|
||||||
for f in path.rglob("*"):
|
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 ──────────────────────────────────────────────────────────────
|
# ── Auth ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/auth/login")
|
@app.route("/auth/login")
|
||||||
|
|
@ -544,10 +658,83 @@ def delete_file():
|
||||||
if top in _HIDDEN or top.startswith("."):
|
if top in _HIDDEN or top.startswith("."):
|
||||||
abort(403)
|
abort(403)
|
||||||
parent = str(Path(subpath).parent)
|
parent = str(Path(subpath).parent)
|
||||||
target.unlink()
|
_trash_move(target, subpath)
|
||||||
return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse"))
|
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 ─────────────────────────────────────────────
|
# ── Renommage de fichiers ─────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/rename", methods=["POST"])
|
@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 { 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: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; }
|
.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; }
|
.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; }
|
.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; }
|
.col-date { display: none; }
|
||||||
.stat-box .value { font-size: 1.4rem; }
|
.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>
|
{% if request.endpoint == 'browse' %}class="active"{% endif %}>Parcourir</a>
|
||||||
<a href="{{ url_for('stats') }}"
|
<a href="{{ url_for('stats') }}"
|
||||||
{% if request.endpoint in ('stats', 'stats_report') %}class="active"{% endif %}>Statistiques</a>
|
{% 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>
|
</nav>
|
||||||
<form class="header-search" action="{{ url_for('search') }}" method="get" role="search">
|
<form class="header-search" action="{{ url_for('search') }}" method="get" role="search">
|
||||||
<input type="search" name="q" placeholder="Rechercher…"
|
<input type="search" name="q" placeholder="Rechercher…"
|
||||||
|
|
|
||||||
|
|
@ -18,92 +18,8 @@
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{% if entries or subpath %}
|
{% if entries or subpath %}
|
||||||
<table class="file-table">
|
{% set mode = 'browse' %}
|
||||||
<thead>
|
{% include '_file_table.html' %}
|
||||||
<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>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty">Dossier vide.</p>
|
<p class="empty">Dossier vide.</p>
|
||||||
{% endif %}
|
{% 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