mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
feat(admin): cleanup page — remove 'Fichiers temporaires' level, promote sections to h2 TOC entries
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user