feat(admin): cleanup page — remove 'Fichiers temporaires' level, promote sections to h2 TOC entries

This commit is contained in:
Pontoporeia
2026-09-18 16:26:49 +02:00
parent 1ed69a2c1a
commit 3f352b0d26
18 changed files with 650 additions and 252 deletions
+34
View File
@@ -1142,6 +1142,31 @@ th.admin-ap-col {
margin-left: var(--space-2xs);
}
/* Stat line rendered as a card under a cleanup-section heading. */
.cleanup-stat-card {
margin: 0 0 var(--space-m);
padding: var(--space-s) var(--space-l);
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius);
font-size: var(--step-1);
font-weight: 700;
color: var(--accent-primary);
display: flex;
flex-wrap: wrap;
gap: var(--space-2xs) var(--space-m);
align-items: baseline;
}
/* Short French explanation shown under a cleanup-section heading. */
.cleanup-section-desc {
margin: 0 0 var(--space-m);
font-size: var(--step--1);
line-height: 1.5;
color: var(--text-secondary);
max-width: 70ch;
}
.n-grid {
display: block;
}
@@ -2284,6 +2309,15 @@ th.admin-ap-col {
margin-bottom: var(--space-m);
}
/* Cleanup page: sections are loaded asynchronously into a wrapper div, so
give them the same top-level spacing as direct article sections. */
#tmp-cleanup-stats-wrapper > section[aria-labelledby] {
margin-bottom: var(--space-xl);
border: none;
border-radius: 0;
padding: 0;
}
/* Admin TOC: same <details class="toc"> as public pages, positioned sticky */
#admin-toc .toc-list {
padding-top: var(--space-2xs);
@@ -167,3 +167,59 @@
.btn--red:hover {
filter: brightness(0.9);
}
/* Tooltips — display when a .btn carries a data-tip attribute.
Shown below the button on hover/focus, positioned above the
toolbar so it never clips inside the scrollable table. */
.btn[data-tip] {
position: relative;
}
.btn[data-tip]::after {
content: attr(data-tip);
position: absolute;
left: 50%;
bottom: calc(100% + 8px);
transform: translateX(-50%);
width: max-content;
max-width: 260px;
padding: var(--space-3xs) var(--space-xs);
border-radius: var(--radius);
background: var(--text-primary);
color: var(--bg-primary);
font-size: var(--step--2);
font-weight: 500;
letter-spacing: 0.02em;
line-height: 1.3;
text-align: center;
opacity: 0;
pointer-events: none;
z-index: 1000;
transition: opacity 0.12s ease;
}
.btn[data-tip]:hover::after,
.btn[data-tip]:focus::after {
opacity: 1;
}
/* Small arrow below the bubble pointing at the button */
.btn[data-tip]::before {
content: '';
position: absolute;
left: 50%;
bottom: calc(100% + 4px);
transform: translateX(-50%);
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 4px solid var(--text-primary);
opacity: 0;
pointer-events: none;
z-index: 1000;
transition: opacity 0.12s ease;
}
.btn[data-tip]:hover::before,
.btn[data-tip]:focus::before {
opacity: 1;
}
+16 -21
View File
@@ -5,12 +5,8 @@
* All functions are attached to `window` for onclick handlers in PHP templates.
*/
(() => {
function cleanupToggleAll(src, group) {
const selector =
group === "filepond"
? 'input[name="filepond_dirs[]"]'
: 'input[name="trash_files[]"]';
document.querySelectorAll(selector).forEach((cb) => {
function cleanupToggleAll(src) {
document.querySelectorAll('input[name="trash_files[]"]').forEach((cb) => {
cb.checked = src.checked;
});
cleanupUpdateBulk();
@@ -23,7 +19,7 @@
if (!bar || !countEl) return;
const allChecked = document.querySelectorAll(
'input[name="filepond_dirs[]"]:checked, input[name="trash_files[]"]:checked',
'input[name="trash_files[]"]:checked',
);
const n = allChecked.length;
@@ -48,13 +44,11 @@
// Populate hidden inputs from checked checkboxes
container.innerHTML = "";
document
.querySelectorAll(
'input[name="filepond_dirs[]"]:checked, input[name="trash_files[]"]:checked',
)
.querySelectorAll('input[name="trash_files[]"]:checked')
.forEach((cb) => {
const inp = document.createElement("input");
inp.type = "hidden";
inp.name = cb.name; // "filepond_dirs[]" or "trash_files[]"
inp.name = cb.name; // "trash_files[]"
inp.value = cb.value;
container.appendChild(inp);
});
@@ -65,13 +59,17 @@
function cleanupBulkRestore() {
const form = document.getElementById("cleanup-bulk-restore-form");
const container = document.getElementById("cleanup-bulk-restore-checkboxes");
const container = document.getElementById(
"cleanup-bulk-restore-checkboxes",
);
if (!form || !container) return;
container.innerHTML = "";
let count = 0;
document
.querySelectorAll('input[name="trash_files[]"]:checked[data-restorable="1"]')
.querySelectorAll(
'input[name="trash_files[]"]:checked[data-restorable="1"]',
)
.forEach((cb) => {
const inp = document.createElement("input");
inp.type = "hidden";
@@ -83,19 +81,16 @@
if (count === 0) return;
if (!confirm(`Restaurer ${count} fichier(s) vers leur(s) TFE associé(s) ?`)) return;
if (!confirm(`Restaurer ${count} fichier(s) vers leur(s) TFE associé(s) ?`))
return;
htmx.trigger(form, "submit");
}
function reattachListeners() {
document
.querySelectorAll(
'input[name="filepond_dirs[]"], input[name="trash_files[]"]',
)
.forEach((cb) => {
cb.addEventListener("change", cleanupUpdateBulk);
});
document.querySelectorAll('input[name="trash_files[]"]').forEach((cb) => {
cb.addEventListener("change", cleanupUpdateBulk);
});
cleanupUpdateBulk();
}
+84 -36
View File
@@ -7,63 +7,92 @@
* section[aria-labelledby] headings inside #main-content.
* Hides the TOC if fewer than 2 sections exist.
*
* Guarded: only runs once.
* Rebuilds whenever HTMX swaps content so sections that are loaded
* asynchronously (e.g. cleanup page fragments) appear in the TOC.
*/
(() => {
if (window.__adminTocBuilt) return;
window.__adminTocBuilt = true;
let observer = null;
let items = [];
// Generate a spread of thresholds for smooth IntersectionObserver firing.
function buildThresholds() {
const t = [];
for (let i = 0; i <= 20; i++) t.push(i / 20);
return t;
}
function tearDown() {
if (observer) {
observer.disconnect();
observer = null;
}
items = [];
}
function build() {
var main = document.getElementById("main-content");
var toc = document.getElementById("admin-toc");
var list = document.getElementById("admin-toc-list");
const main = document.getElementById("main-content");
const toc = document.getElementById("admin-toc");
const list = document.getElementById("admin-toc-list");
if (!main || !toc || !list) return;
// Guard against double population
if (list.children.length > 0) return;
tearDown();
var sections = main.querySelectorAll("section[aria-labelledby]");
// Repopulate from scratch.
list.textContent = "";
const sections = main.querySelectorAll("section[aria-labelledby]");
if (sections.length < 2) {
toc.hidden = true;
return;
}
toc.hidden = false;
var items = [];
sections.forEach((sec) => {
var headingId = sec.getAttribute("aria-labelledby");
var heading = document.getElementById(headingId);
const headingId = sec.getAttribute("aria-labelledby");
const heading = document.getElementById(headingId);
if (!heading) return;
if (!sec.id) sec.id = headingId;
var a = document.createElement("a");
a.href = `#${sec.id}`;
a.textContent = heading.textContent.trim();
// Anchor the link to the heading id. Do NOT assign the heading id to
// the <section> itself — that would create a duplicate id on rebuild
// (getElementById would then return the section, whose textContent
// drags in the whole table).
const anchorId = headingId;
var li = document.createElement("li");
// Label excludes .n-meta counters (e.g. “Corbeille 5 fichiers · 2.1 GB”
// should read just “Corbeille” in the TOC).
let label;
if (heading.querySelector?.(".n-meta")) {
const clone = heading.cloneNode(true);
clone.querySelectorAll(".n-meta").forEach((n) => {
n.remove();
});
label = clone.textContent.trim();
} else {
label = heading.textContent.trim();
}
const a = document.createElement("a");
a.href = `#${anchorId}`;
a.textContent = label;
const li = document.createElement("li");
li.appendChild(a);
list.appendChild(li);
items.push({ section: sec, link: a });
});
// Generate a spread of thresholds for smooth IntersectionObserver firing.
function buildThresholds() {
var t = [];
for (var i = 0; i <= 20; i++) t.push(i / 20);
return t;
}
var observer = new IntersectionObserver(
observer = new IntersectionObserver(
() => {
// Active section = whose top is closest to the midpoint of the
// viewport from above. At page bottom, the last section wins
// even if its heading can't reach 50%.
var line = window.innerHeight * 0.5;
var best = null;
var bestDist = Infinity;
const line = window.innerHeight * 0.5;
let best = null;
let bestDist = Infinity;
// Pass 1: sections whose top is above/at the line
items.forEach((item) => {
var top = item.section.getBoundingClientRect().top;
const top = item.section.getBoundingClientRect().top;
if (top <= line && line - top < bestDist) {
bestDist = line - top;
best = item;
@@ -73,8 +102,8 @@
// Pass 2: no section above the line — pick closest from below
if (!best) {
items.forEach((item) => {
var top = item.section.getBoundingClientRect().top;
var dist = top - line;
const top = item.section.getBoundingClientRect().top;
const dist = top - line;
if (dist >= 0 && dist < bestDist) {
bestDist = dist;
best = item;
@@ -85,10 +114,10 @@
// Pass 3: at page bottom, if the last section is visible but
// its heading never crossed 50%, promote it anyway.
if (best) {
var lastItem = items[items.length - 1];
const lastItem = items[items.length - 1];
if (lastItem !== best) {
var lastTop = lastItem.section.getBoundingClientRect().top;
var atBottom =
const lastTop = lastItem.section.getBoundingClientRect().top;
const atBottom =
window.innerHeight + window.scrollY + 5 >=
document.documentElement.scrollHeight;
if (atBottom && lastTop > line && lastTop < window.innerHeight) {
@@ -110,9 +139,28 @@
});
}
function run() {
if (document.getElementById("admin-toc")) build();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", build);
document.addEventListener("DOMContentLoaded", run);
} else {
build();
run();
}
// HTMX loads many admin sections asynchronously (cleanup fragments, tables).
// Rebuild the TOC whenever the content around it changes.
if (window.htmx) {
document.body.addEventListener("htmx:afterSwap", (e) => {
if (e.target?.closest?.("#main-content")) {
run();
}
});
document.body.addEventListener("htmx:afterSettle", (e) => {
if (e.target?.closest?.("#main-content")) {
run();
}
});
}
})();