Rework contenus-edit: auto-save, OverType toolbar, dynamic sidebar links

- Auto-save: new autosave.js with 1.5s debounce, watches all forms with
  data-autosave, POSTs to form action with Accept: application/json, shows
  saving/saved/error status indicator
- All action handlers (page.php, apropos.php, form-help.php) now detect
  JSON Accept header and return {success, csrf_token} or {error} responses
- OverType toolbar enabled (toolbar:true) on all three markdown editors
  (page, about_page, form_help)
- Sidebar links: replaced fixed erg_site_url / source_code_url rows with
  dynamic sidebar_links array of {label, url} objects. Add/remove via JS.
  Fallback migration reads legacy keys if sidebar_links is empty.
- Updated AboutController and about.php template to render dynamic links
- Updated apropos.css: unified .apropos-toc-link replacing .apropos-toc-erg
  and .apropos-toc-source
- New CSS: autosave-status states, sidebar-link-row layout
- Removed all Enregistrer + Annuler buttons — auto-save and h1 back-arrow
  make them redundant
This commit is contained in:
Pontoporeia
2026-06-09 17:10:49 +02:00
parent a45a2c9ac4
commit c4a550f9d1
13 changed files with 441 additions and 93 deletions

View File

@@ -2140,3 +2140,63 @@ th.admin-ap-col {
width: 100%;
box-sizing: border-box;
}
/* ── Sidebar links editor ──────────────────────────────────────────────── */
.sidebar-link-row {
display: flex;
align-items: flex-start;
gap: var(--space-xs);
margin-bottom: var(--space-s);
}
.sidebar-link-fields {
display: grid;
grid-template-columns: 1fr 2fr;
gap: var(--space-s);
flex: 1;
}
.sidebar-link-fields > div {
display: flex;
flex-direction: column;
}
.sidebar-link-fields label {
font-size: var(--step--1);
margin-bottom: var(--space-3xs);
}
.sidebar-link-fields input {
width: 100%;
box-sizing: border-box;
}
.sidebar-link-row .admin-icon-btn--delete {
margin-top: calc(var(--step--1) + var(--space-3xs) + 0.3em);
flex-shrink: 0;
}
/* ── Auto-save status indicator ────────────────────────────────────────── */
.autosave-status {
font-size: var(--step--2);
min-height: 1.5em;
margin-top: var(--space-2xs);
color: var(--text-tertiary);
transition: color 0.2s;
}
.autosave-status--saving {
color: var(--accent-yellow);
font-style: italic;
}
.autosave-status--saved {
color: var(--accent-green);
}
.autosave-status--error {
color: var(--error);
font-weight: 500;
}

View File

@@ -71,35 +71,23 @@
border-left-color: var(--accent-primary);
}
.apropos-toc-erg {
.apropos-toc-link:first-of-type {
padding-top: var(--space-s);
border-top: 1px solid var(--border-primary);
}
.apropos-toc-erg a {
font-size: var(--step--2);
color: var(--accent-primary);
text-decoration: none;
transition: opacity 0.15s;
}
.apropos-toc-erg a:hover {
color: var(--accent-primary);
opacity: 1;
}
.apropos-toc-source {
.apropos-toc-link + .apropos-toc-link {
padding-top: var(--space-xs);
}
.apropos-toc-source a {
.apropos-toc-link a {
font-size: var(--step--2);
color: var(--accent-primary);
text-decoration: none;
transition: opacity 0.15s;
}
.apropos-toc-source a:hover {
.apropos-toc-link a:hover {
color: var(--accent-primary);
opacity: 1;
}
@@ -326,7 +314,7 @@
padding-left: 0;
}
.apropos-toc-erg {
.apropos-toc-link {
border-top: none;
padding-top: 0;
margin-left: auto;

View File

@@ -0,0 +1,76 @@
/**
* Auto-save for admin content edit forms.
*
* Watches forms with [data-autosave] attribute. Debounces 1.5s after the
* last change, POSTs to the form's action, and shows a small status bar.
*
* The status indicator lives in a sibling element with [data-autosave-status].
* States: idle → saving… → saved ✓ / error !
*/
(() => {
const forms = document.querySelectorAll('form[data-autosave]');
if (!forms.length) return;
const DEBOUNCE_MS = 1500;
for (const form of forms) {
const statusEl = document.querySelector(form.dataset.autosaveStatus || '[data-autosave-status]');
let timer = null;
let dirty = false;
const setStatus = (text, cls) => {
if (!statusEl) return;
statusEl.textContent = text;
statusEl.className = 'autosave-status ' + (cls || '');
};
const doSave = () => {
if (!dirty) return;
dirty = false;
setStatus('Enregistrement…', 'autosave-status--saving');
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData,
headers: { 'Accept': 'application/json' },
})
.then((r) => {
if (!r.ok) throw new Error('HTTP ' + r.status);
setStatus('Enregistré ✓', 'autosave-status--saved');
// Refresh CSRF token from response if provided
r.json().then((data) => {
if (data && data.csrf_token) {
const csrfInput = form.querySelector('input[name="csrf_token"]');
if (csrfInput) csrfInput.value = data.csrf_token;
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) meta.setAttribute('content', data.csrf_token);
}
}).catch(() => {});
})
.catch(() => {
setStatus('Erreur !', 'autosave-status--error');
dirty = true;
});
};
const schedule = () => {
dirty = true;
setStatus('', '');
clearTimeout(timer);
timer = setTimeout(doSave, DEBOUNCE_MS);
};
// Watch all inputs inside the form
form.addEventListener('input', schedule);
form.addEventListener('change', schedule);
// Clear dirty on manual submit
form.addEventListener('submit', () => {
clearTimeout(timer);
dirty = false;
setStatus('', '');
});
}
})();