feat: migrate admin system page to HTMX with tab-based navigation and log viewer

This commit is contained in:
Pontoporeia
2026-04-16 14:45:50 +02:00
parent 05002ccee4
commit b03be51b92
10 changed files with 146 additions and 165 deletions

1
.gitignore vendored
View File

@@ -24,3 +24,4 @@ Thumbs.db
.vscode/ .vscode/
.idea/ .idea/
/node_modules

View File

@@ -12,3 +12,4 @@
- [x] Delete public/admin/pages-edit.php - [x] Delete public/admin/pages-edit.php
- [x] Delete public/admin/actions/page.php - [x] Delete public/admin/actions/page.php
- [x] Update storage/schema.sql with apropos_contents table + trigger - [x] Update storage/schema.sql with apropos_contents table + trigger
- [x] Rework system.php/system.js: replace custom fetch() JS with HTMX, inline onclick for copy + collapse

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "posterg",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"vendor": "cp node_modules/htmx.org/dist/htmx.min.js public/assets/js/htmx.min.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.33.0",
"dependencies": {
"htmx.org": "^2.0.8"
}
}

22
pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,22 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
htmx.org:
specifier: ^2.0.8
version: 2.0.8
packages:
htmx.org@2.0.8:
resolution: {integrity: sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==}
snapshots:
htmx.org@2.0.8: {}

View File

@@ -77,7 +77,8 @@ if ($tab === 'nginx_config') {
<div class="log-empty">Le fichier de configuration est vide.</div> <div class="log-empty">Le fichier de configuration est vide.</div>
<?php else: ?> <?php else: ?>
<div class="log-output" id="log-output" role="region" aria-label="Configuration nginx"> <div class="log-output" id="log-output" role="region" aria-label="Configuration nginx">
<button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier la configuration">Copier</button> <button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier la configuration"
onclick="copyLogContent(this);return false">Copier</button>
<?php foreach ($lines as $i => $line): ?> <?php foreach ($lines as $i => $line): ?>
<span class="log-line <?= SystemController::nginxLineClass($line) ?>" <span class="log-line <?= SystemController::nginxLineClass($line) ?>"
data-n="<?= $i + 1 ?>"><?= htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE) ?></span> data-n="<?= $i + 1 ?>"><?= htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE) ?></span>
@@ -93,12 +94,19 @@ if ($tab === 'nginx_config') {
$logMeta = $data['meta']; $logMeta = $data['meta'];
?> ?>
<div class="log-toolbar"> <div class="log-toolbar">
<label for="lines-select">Afficher</label> <form id="lines-form" hx-get="/admin/system-fragment.php"
<select id="lines-select" aria-label="Nombre de lignes"> hx-target="#sys-tab-panel"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
hx-trigger="change"
hx-vals='{"tab":"<?= htmlspecialchars($tab) ?>"}'>
<label for="lines-select">Afficher</label>
<select id="lines-select" name="n" aria-label="Nombre de lignes">
<?php foreach (SystemController::ALLOWED_LINES as $opt): ?> <?php foreach (SystemController::ALLOWED_LINES as $opt): ?>
<option value="<?= $opt ?>" <?= $opt === $n ? 'selected' : '' ?>><?= $opt ?> dernières lignes</option> <option value="<?= $opt ?>" <?= $opt === $n ? 'selected' : '' ?>><?= $opt ?> dernières lignes</option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</form>
<?php if ($logLines !== null && count($logLines) > 0): ?> <?php if ($logLines !== null && count($logLines) > 0): ?>
<span class="log-count-badge"><?= count($logLines) ?> ligne(s)</span> <span class="log-count-badge"><?= count($logLines) ?> ligne(s)</span>
<?php endif; ?> <?php endif; ?>
@@ -127,7 +135,8 @@ if ($tab === 'nginx_config') {
<div class="log-empty">Le fichier journal est vide.</div> <div class="log-empty">Le fichier journal est vide.</div>
<?php else: ?> <?php else: ?>
<div class="log-output" id="log-output" role="log" aria-live="off" aria-label="Contenu du journal"> <div class="log-output" id="log-output" role="log" aria-live="off" aria-label="Contenu du journal">
<button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier le contenu">Copier</button> <button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier le contenu"
onclick="copyLogContent(this);return false">Copier</button>
<?php foreach ($logLines as $i => $line): ?> <?php foreach ($logLines as $i => $line): ?>
<span class="log-line <?= SystemController::logLineClass($line) ?>" <span class="log-line <?= SystemController::logLineClass($line) ?>"
data-n="<?= count($logLines) - $i ?>"><?= htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE) ?></span> data-n="<?= count($logLines) - $i ?>"><?= htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE) ?></span>

View File

@@ -70,8 +70,12 @@ if ($activeTab === 'nginx_config') {
$isAdmin = true; $bodyClass = 'admin-body'; $isAdmin = true; $bodyClass = 'admin-body';
$extraCss = ['/assets/css/system.css']; $extraCss = ['/assets/css/system.css'];
$extraJs = ['/assets/js/system.js']; // HTMX loaded once in footer; status collapse + copy via inline JS
require_once APP_ROOT . '/templates/head.php'; require_once APP_ROOT . '/templates/head.php';
// Restore collapsed state from cookie
$collapsed = $_COOKIE['sys_collapsed'] ?? null;
$statusInitiallyCollapsed = $collapsed === '1';
?> ?>
<?php include APP_ROOT . '/templates/header.php'; ?> <?php include APP_ROOT . '/templates/header.php'; ?>
@@ -103,11 +107,14 @@ require_once APP_ROOT . '/templates/head.php';
<?php endif; ?> <?php endif; ?>
</h2> </h2>
<button id="sys-status-toggle" class="sys-status-toggle" <button id="sys-status-toggle" class="sys-status-toggle"
aria-expanded="true" aria-controls="sys-status-body" aria-expanded="<?= $statusInitiallyCollapsed ? 'false' : 'true' ?>" aria-controls="sys-status-body"
type="button">▲ Réduire</button> type="button"
onclick="var b=document.getElementById('sys-status-body');var c=b.hidden;b.hidden=!c;this.setAttribute('aria-expanded',c);this.textContent=c?'▲ Réduire':'▼ Développer';document.cookie='sys_collapsed='+(!c)+';path=/;max-age=31536000';return false">
<?= $statusInitiallyCollapsed ? '▼ Développer' : '▲ Réduire' ?>
</button>
</div> </div>
<div id="sys-status-body"> <div id="sys-status-body"<?= $statusInitiallyCollapsed ? ' hidden' : '' ?>>
<div class="srv-grid"> <div class="srv-grid">
<?php foreach ($checks as $check): ?> <?php foreach ($checks as $check): ?>
<?php $st = $check['status'] ?? 'unknown'; ?> <?php $st = $check['status'] ?? 'unknown'; ?>
@@ -154,6 +161,11 @@ require_once APP_ROOT . '/templates/head.php';
<?php foreach (SystemController::LOG_FILES as $key => $def): ?> <?php foreach (SystemController::LOG_FILES as $key => $def): ?>
<a href="?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>" <a href="?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
class="sys-tab <?= $activeTab === $key ? 'active' : '' ?>" class="sys-tab <?= $activeTab === $key ? 'active' : '' ?>"
hx-get="/admin/system-fragment.php?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
hx-target="#sys-tab-panel"
hx-push-url="?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
data-tab="<?= htmlspecialchars($key) ?>" data-tab="<?= htmlspecialchars($key) ?>"
<?= $activeTab === $key ? 'aria-current="page"' : '' ?>> <?= $activeTab === $key ? 'aria-current="page"' : '' ?>>
<?= htmlspecialchars($def['label']) ?> <?= htmlspecialchars($def['label']) ?>
@@ -161,14 +173,17 @@ require_once APP_ROOT . '/templates/head.php';
<?php endforeach; ?> <?php endforeach; ?>
<a href="?tab=nginx_config" <a href="?tab=nginx_config"
class="sys-tab <?= $activeTab === 'nginx_config' ? 'active' : '' ?>" class="sys-tab <?= $activeTab === 'nginx_config' ? 'active' : '' ?>"
hx-get="/admin/system-fragment.php?tab=nginx_config"
hx-target="#sys-tab-panel"
hx-push-url="?tab=nginx_config"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
data-tab="nginx_config" data-tab="nginx_config"
<?= $activeTab === 'nginx_config' ? 'aria-current="page"' : '' ?>>nginx — config</a> <?= $activeTab === 'nginx_config' ? 'aria-current="page"' : '' ?>>nginx — config</a>
</nav> </nav>
<!-- Tab panel — content swapped by fetch(); data-* attrs drive JS state --> <!-- Tab panel — content swapped by HTMX -->
<div id="sys-tab-panel" <div id="sys-tab-panel">
data-tab="<?= htmlspecialchars($activeTab) ?>"
data-n="<?= $selectedN ?>">
<?php if ($activeTab === 'nginx_config'): ?> <?php if ($activeTab === 'nginx_config'): ?>
<!-- ════════════════════════════════════════════════════════════════════ <!-- ════════════════════════════════════════════════════════════════════
@@ -205,7 +220,8 @@ require_once APP_ROOT . '/templates/head.php';
<?php else: ?> <?php else: ?>
<div class="log-output" id="log-output" role="region" aria-label="Configuration nginx"> <div class="log-output" id="log-output" role="region" aria-label="Configuration nginx">
<button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier la configuration"> <button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier la configuration"
onclick="copyLogContent(this);return false">
Copier Copier
</button> </button>
<?php foreach ($nginxConfigLines as $i => $line): ?> <?php foreach ($nginxConfigLines as $i => $line): ?>
@@ -223,13 +239,21 @@ require_once APP_ROOT . '/templates/head.php';
<!-- Lines selector (submits via JS on change; no button needed) --> <!-- Lines selector (submits via JS on change; no button needed) -->
<div class="log-toolbar"> <div class="log-toolbar">
<label for="lines-select">Afficher</label> <label for="lines-select">Afficher</label>
<select id="lines-select" aria-label="Nombre de lignes"> <form id="lines-form" hx-get="/admin/system-fragment.php"
hx-target="#sys-tab-panel"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
hx-trigger="change"
hx-vals='{"tab":"<?= htmlspecialchars($activeTab) ?>"}'>
<label for="lines-select">Afficher</label>
<select id="lines-select" name="n" aria-label="Nombre de lignes">
<?php foreach (SystemController::ALLOWED_LINES as $n): ?> <?php foreach (SystemController::ALLOWED_LINES as $n): ?>
<option value="<?= $n ?>" <?= $n === $selectedN ? 'selected' : '' ?>> <option value="<?= $n ?>" <?= $n === $selectedN ? 'selected' : '' ?>>
<?= $n ?> dernières lignes <?= $n ?> dernières lignes
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</form>
<?php if ($logLines !== null && count($logLines) > 0): ?> <?php if ($logLines !== null && count($logLines) > 0): ?>
<span class="log-count-badge"><?= count($logLines) ?> ligne(s)</span> <span class="log-count-badge"><?= count($logLines) ?> ligne(s)</span>
<?php endif; ?> <?php endif; ?>
@@ -262,7 +286,8 @@ require_once APP_ROOT . '/templates/head.php';
<?php else: ?> <?php else: ?>
<div class="log-output" id="log-output" role="log" aria-live="off" aria-label="Contenu du journal"> <div class="log-output" id="log-output" role="log" aria-live="off" aria-label="Contenu du journal">
<button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier le contenu"> <button class="log-copy-btn" id="log-copy-btn" type="button" title="Copier le contenu"
onclick="copyLogContent(this);return false">
Copier Copier
</button> </button>
<?php foreach ($logLines as $i => $line): ?> <?php foreach ($logLines as $i => $line): ?>
@@ -278,5 +303,56 @@ require_once APP_ROOT . '/templates/head.php';
</main> </main>
<script>
function copyLogContent(btn) {
var logOut = document.querySelector('#log-output');
if (!logOut) return;
var text = Array.from(logOut.querySelectorAll('.log-line'))
.map(function(el){ return el.textContent; }).join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function(){
btn.textContent = '\u2713 Copi\u00e9';
btn.classList.add('copied');
setTimeout(function(){ btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
});
} else {
fallbackCopy(text, btn);
}
}
function fallbackCopy(text, btn) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); btn.textContent = '\u2713 Copi\u00e9'; btn.classList.add('copied');
setTimeout(function(){ btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
} catch(e) {}
document.body.removeChild(ta);
}
// Update active tab class after each HTMX swap on #sys-tab-panel
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'sys-tab-panel') {
var rc = evt.detail.requestConfig;
var tab = null;
// Tab clicks carry ?tab=… in the path
var qIdx = rc.path.indexOf('?');
if (qIdx !== -1) {
tab = new URLSearchParams(rc.path.substring(qIdx + 1)).get('tab');
}
// Line-count form sends tab via hx-vals in parameters
if (!tab && rc.parameters && rc.parameters.tab) {
tab = rc.parameters.tab;
}
if (tab) {
document.querySelectorAll('.sys-tabs .sys-tab').forEach(function(a) {
var isActive = a.getAttribute('data-tab') === tab;
a.classList.toggle('active', isActive);
if (isActive) a.setAttribute('aria-current', 'page');
else a.removeAttribute('aria-current');
});
}
}
});
</script>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?> <?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -184,12 +184,12 @@
min-height: 8rem; min-height: 8rem;
position: relative; position: relative;
} }
#sys-tab-panel.sys-panel-loading { #sys-tab-panel.htmx-request {
opacity: 0.4; opacity: 0.4;
pointer-events: none; pointer-events: none;
transition: opacity 0.1s; transition: opacity 0.1s;
} }
#sys-tab-panel.sys-panel-loading::after { #sys-tab-panel.htmx-request::after {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;

File diff suppressed because one or more lines are too long

View File

@@ -1,146 +0,0 @@
(function () {
// ── Status section collapse toggle ────────────────────────────────
var toggleBtn = document.getElementById('sys-status-toggle');
var statusBody = document.getElementById('sys-status-body');
if (toggleBtn && statusBody) {
toggleBtn.addEventListener('click', function () {
var collapsed = statusBody.hidden;
statusBody.hidden = !collapsed;
toggleBtn.setAttribute('aria-expanded', collapsed ? 'true' : 'false');
toggleBtn.textContent = collapsed ? '▲ Réduire' : '▼ Développer';
try { localStorage.setItem('sys_status_collapsed', collapsed ? '0' : '1'); } catch(e) {}
});
try {
if (localStorage.getItem('sys_status_collapsed') === '1') {
statusBody.hidden = true;
toggleBtn.setAttribute('aria-expanded', 'false');
toggleBtn.textContent = '▼ Développer';
}
} catch(e) {}
}
// ── fetch()-based tab panel loading ───────────────────────────────
var panel = document.getElementById('sys-tab-panel');
var tabNav = document.querySelector('.sys-tabs');
if (!panel || !tabNav || !window.fetch) return;
var currentTab = panel.dataset.tab;
var currentN = panel.dataset.n;
function loadPanel(tab, n, pushState) {
var url = new URL(window.location.href);
var fragUrl = new URL('/admin/system-fragment.php', window.location.origin);
fragUrl.searchParams.set('tab', tab);
fragUrl.searchParams.set('n', n);
// Mark the panel as loading
panel.classList.add('sys-panel-loading');
fetch(fragUrl.toString(), { credentials: 'same-origin' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
})
.then(function (html) {
panel.innerHTML = html;
panel.dataset.tab = tab;
panel.dataset.n = n;
currentTab = tab;
currentN = n;
panel.classList.remove('sys-panel-loading');
// Update active tab indicators
tabNav.querySelectorAll('.sys-tab').forEach(function (a) {
var isActive = a.dataset.tab === tab;
a.classList.toggle('active', isActive);
if (isActive) {
a.setAttribute('aria-current', 'page');
} else {
a.removeAttribute('aria-current');
}
});
// Re-bind inner controls (lines-select, copy btn)
bindPanelControls();
// Update browser URL without reloading
if (pushState) {
url.searchParams.set('tab', tab);
url.searchParams.set('n', n);
history.pushState({ tab: tab, n: n }, '', url.toString());
}
})
.catch(function () {
// Graceful degradation: fall back to full page load
panel.classList.remove('sys-panel-loading');
url.searchParams.set('tab', tab);
url.searchParams.set('n', n);
window.location.href = url.toString();
});
}
// Wire tab links
tabNav.querySelectorAll('.sys-tab').forEach(function (a) {
a.addEventListener('click', function (e) {
e.preventDefault();
var tab = a.dataset.tab;
if (tab && tab !== currentTab) {
loadPanel(tab, currentN, true);
}
});
});
// Handle browser back/forward
window.addEventListener('popstate', function (e) {
if (e.state && e.state.tab) {
loadPanel(e.state.tab, e.state.n || 100, false);
}
});
function bindPanelControls() {
// Lines-select
var sel = panel.querySelector('#lines-select');
if (sel) {
sel.addEventListener('change', function () {
loadPanel(currentTab, parseInt(this.value, 10), true);
});
}
// Copy button
var copyBtn = panel.querySelector('#log-copy-btn');
var logOut = panel.querySelector('#log-output');
if (copyBtn && logOut) {
copyBtn.addEventListener('click', function () {
var text = Array.from(logOut.querySelectorAll('.log-line'))
.map(function (el) { return el.textContent; })
.join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
copyBtn.textContent = '✓ Copié';
copyBtn.classList.add('copied');
setTimeout(function () {
copyBtn.textContent = 'Copier';
copyBtn.classList.remove('copied');
}, 2000);
}).catch(function () { fallbackCopy(text); });
} else {
fallbackCopy(text);
}
});
}
}
// Bind controls already present on first load
bindPanelControls();
function fallbackCopy(text) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (e) {}
document.body.removeChild(ta);
}
})();

View File

@@ -4,5 +4,6 @@
<?php if (!empty($extraJsInline)): ?> <?php if (!empty($extraJsInline)): ?>
<script><?= $extraJsInline ?></script> <script><?= $extraJsInline ?></script>
<?php endif; ?> <?php endif; ?>
<script src="/assets/js/htmx.min.js"></script>
</body> </body>
</html> </html>