mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
perf(admin): infinite-scroll the contenus langues/mots-clés tables
The contenus page loaded both lookup tables in full via HTMX on page load: 760 tag rows + 216 language rows, ~2.4MB of HTML including ~1960 inline SVG icons and per-row CSRF forms. The DB queries were already fast (~6ms); the cost was pure client-side payload and DOM. - Add paged lookups: getTagsPage/getLanguagesPage + countTagsWithCount/ countLanguagesWithCount, and optional limit/offset on the existing unpaged variants (backward compatible). - Fragments serve 25 rows per request. A sentinel <tr> with hx-trigger="intersect once root:#<wrap>" appends the next page when it scrolls into the table's own scroll container (htmx 'revealed' only checks the window viewport, not nested scrollers). - Search forms still swap the whole wrapper and reset to offset 0. - Style the load-more row; add PagedLanguagesTagsTest coverage. Initial DOM for the page drops from ~2.4MB to ~160KB; scrolling reaches the full totals (736 tags, 216 languages) and stops cleanly.
This commit is contained in:
@@ -4,6 +4,15 @@
|
||||
*
|
||||
* HTMX fragment: returns the langues table for the contenus page,
|
||||
* optionally filtered by a search query.
|
||||
*
|
||||
* Infinite scroll: rows are served in pages of PAGE_SIZE. When there are
|
||||
* more rows, a sentinel <tr> is emitted with hx-trigger="revealed": when it
|
||||
* scrolls into view htmx fetches the next offset and swaps itself
|
||||
* (outerHTML) with the next chunk of rows + a new sentinel. This keeps the
|
||||
* initial payload small instead of shipping all languages at once.
|
||||
*
|
||||
* When $offset > 0 the fragment returns only the <tr> rows (append mode),
|
||||
* because the sentinel lives inside the existing <tbody>.
|
||||
*/
|
||||
require_once __DIR__ . '/../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../src/AdminAuth.php';
|
||||
@@ -15,14 +24,83 @@ if (empty($_SESSION['csrf_token'])) {
|
||||
|
||||
require_once __DIR__ . '/../../src/Database.php';
|
||||
|
||||
const LANGUES_PAGE_SIZE = 25;
|
||||
|
||||
$searchQuery = trim($_GET['q'] ?? '');
|
||||
$offset = max(0, (int) ($_GET['offset'] ?? 0));
|
||||
|
||||
try {
|
||||
$db = new Database();
|
||||
$languages = ($searchQuery !== '') ? $db->searchLanguages($searchQuery) : $db->getAllLanguagesWithCount();
|
||||
$db = new Database();
|
||||
$languages = $db->getLanguagesPage($searchQuery, LANGUES_PAGE_SIZE, $offset);
|
||||
$total = $db->countLanguagesWithCount($searchQuery);
|
||||
} catch (Exception $e) {
|
||||
die('<div class="flash-error">Erreur : ' . htmlspecialchars($e->getMessage()) . '</div>');
|
||||
}
|
||||
|
||||
$hasMore = ($offset + count($languages)) < $total;
|
||||
$nextUrl = '/admin/contenus-langues-fragment.php?offset=' . ($offset + LANGUES_PAGE_SIZE)
|
||||
. ($searchQuery !== '' ? '&q=' . urlencode($searchQuery) : '');
|
||||
|
||||
/**
|
||||
* Render one <tr> for a language row.
|
||||
*/
|
||||
function render_langue_row(array $lang): void
|
||||
{
|
||||
?>
|
||||
<tr>
|
||||
<td style="width:1%"><input type="checkbox" name="selected_langs[]" value="<?= (int)$lang['id'] ?>" onchange="languesUpdateBulk()"></td>
|
||||
<td id="lang-name-<?= (int)$lang['id'] ?>" data-name="<?= htmlspecialchars($lang['name']) ?>">
|
||||
<span class="tag-name-cell"><?= htmlspecialchars($lang['name']) ?></span>
|
||||
<button type="button" class="admin-icon-btn admin-icon-btn--edit" title="Renommer"
|
||||
onclick="languesStartRename(<?= (int)$lang['id'] ?>)">
|
||||
<?= icon('pencil-note') ?>
|
||||
</button>
|
||||
</td>
|
||||
<td class="admin-tags-count" style="width:1%;white-space:nowrap"><?= (int)$lang['thesis_count'] ?></td>
|
||||
<td class="admin-actions-col" style="width:1%">
|
||||
<div class="admin-actions">
|
||||
<form method="post" action="actions/language.php" class="admin-inline-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="return" value="/admin/contenus.php">
|
||||
<input type="hidden" name="language_id" value="<?= (int)$lang['id'] ?>">
|
||||
<button type="button" class="admin-icon-btn admin-icon-btn--delete" title="Supprimer"
|
||||
onclick="languesConfirmDelete(this, <?= htmlspecialchars(json_encode($lang['name']), ENT_QUOTES) ?>)">
|
||||
<?= icon('trash') ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the "load more" sentinel row (or nothing when exhausted).
|
||||
*/
|
||||
function render_langues_sentinel(bool $hasMore, string $nextUrl): void
|
||||
{
|
||||
if (!$hasMore) {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
<tr class="admin-load-more" hx-get="<?= htmlspecialchars($nextUrl) ?>"
|
||||
hx-trigger="intersect once root:#langues-table-wrap" hx-swap="outerHTML" hx-target="this">
|
||||
<td colspan="4" class="admin-load-more__cell">Chargement…</td>
|
||||
</tr>
|
||||
<?php
|
||||
}
|
||||
|
||||
// ── Append mode: only the rows, swapped in place of the sentinel ──────────
|
||||
if ($offset > 0) {
|
||||
foreach ($languages as $lang) {
|
||||
render_langue_row($lang);
|
||||
}
|
||||
render_langues_sentinel($hasMore, $nextUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Initial load: full wrapper ────────────────────────────────────────────
|
||||
?>
|
||||
<div id="langues-bulk-actions" class="admin-bulk-actions" style="display:none;position:sticky;top:0;z-index:10">
|
||||
<strong><span id="langues-selected-count">0</span> langue(s) sélectionnée(s)</strong>
|
||||
@@ -68,32 +146,9 @@ try {
|
||||
<tr><td colspan="4" class="admin-empty">Aucune langue trouvée.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($languages as $lang): ?>
|
||||
<tr>
|
||||
<td style="width:1%"><input type="checkbox" name="selected_langs[]" value="<?= (int)$lang['id'] ?>" onchange="languesUpdateBulk()"></td>
|
||||
<td id="lang-name-<?= (int)$lang['id'] ?>" data-name="<?= htmlspecialchars($lang['name']) ?>">
|
||||
<span class="tag-name-cell"><?= htmlspecialchars($lang['name']) ?></span>
|
||||
<button type="button" class="admin-icon-btn admin-icon-btn--edit" title="Renommer"
|
||||
onclick="languesStartRename(<?= (int)$lang['id'] ?>)">
|
||||
<?= icon('pencil-note') ?>
|
||||
</button>
|
||||
</td>
|
||||
<td class="admin-tags-count" style="width:1%;white-space:nowrap"><?= (int)$lang['thesis_count'] ?></td>
|
||||
<td class="admin-actions-col" style="width:1%">
|
||||
<div class="admin-actions">
|
||||
<form method="post" action="actions/language.php" class="admin-inline-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="return" value="/admin/contenus.php">
|
||||
<input type="hidden" name="language_id" value="<?= (int)$lang['id'] ?>">
|
||||
<button type="button" class="admin-icon-btn admin-icon-btn--delete" title="Supprimer"
|
||||
onclick="languesConfirmDelete(this, <?= htmlspecialchars(json_encode($lang['name']), ENT_QUOTES) ?>)">
|
||||
<?= icon('trash') ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php render_langue_row($lang); ?>
|
||||
<?php endforeach; ?>
|
||||
<?php render_langues_sentinel($hasMore, $nextUrl); ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user