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:
Pontoporeia
2026-09-18 16:26:49 +02:00
parent 8016712741
commit 25c5133086
6 changed files with 475 additions and 62 deletions
+82 -27
View File
@@ -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>
+82 -27
View File
@@ -4,6 +4,15 @@
*
* HTMX fragment: returns the mots-clés 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 tags 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 MOTSCLES_PAGE_SIZE = 25;
$searchQuery = trim($_GET['q'] ?? '');
$offset = max(0, (int) ($_GET['offset'] ?? 0));
try {
$db = new Database();
$tags = ($searchQuery !== '') ? $db->searchTags($searchQuery) : $db->getAllTagsWithCount();
$db = new Database();
$tags = $db->getTagsPage($searchQuery, MOTSCLES_PAGE_SIZE, $offset);
$total = $db->countTagsWithCount($searchQuery);
} catch (Exception $e) {
die('<div class="flash-error">Erreur : ' . htmlspecialchars($e->getMessage()) . '</div>');
}
$hasMore = ($offset + count($tags)) < $total;
$nextUrl = '/admin/contenus-motscles-fragment.php?offset=' . ($offset + MOTSCLES_PAGE_SIZE)
. ($searchQuery !== '' ? '&q=' . urlencode($searchQuery) : '');
/**
* Render one <tr> for a tag row.
*/
function render_motscles_row(array $tag): void
{
?>
<tr>
<td style="width:1%"><input type="checkbox" name="selected_tags[]" value="<?= (int)$tag['id'] ?>" onchange="motsclesUpdateBulk()"></td>
<td id="motscles-name-<?= (int)$tag['id'] ?>" data-name="<?= htmlspecialchars($tag['name']) ?>">
<span class="tag-name-cell"><?= htmlspecialchars($tag['name']) ?></span>
<button type="button" class="admin-icon-btn admin-icon-btn--edit" title="Renommer"
onclick="motsclesStartRename(<?= (int)$tag['id'] ?>)">
<?= icon('pencil-note') ?>
</button>
</td>
<td class="admin-tags-count" style="width:1%;white-space:nowrap"><?= (int)$tag['thesis_count'] ?></td>
<td class="admin-actions-col" style="width:1%">
<div class="admin-actions">
<form method="post" action="actions/tag.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="tag_id" value="<?= (int)$tag['id'] ?>">
<button type="button" class="admin-icon-btn admin-icon-btn--delete" title="Supprimer"
onclick="motsclesConfirmDelete(this, <?= htmlspecialchars(json_encode($tag['name']), ENT_QUOTES) ?>)">
<?= icon('trash') ?>
</button>
</form>
</div>
</td>
</tr>
<?php
}
/**
* Render the "load more" sentinel row (or nothing when exhausted).
*/
function render_motscles_sentinel(bool $hasMore, string $nextUrl): void
{
if (!$hasMore) {
return;
}
?>
<tr class="admin-load-more" hx-get="<?= htmlspecialchars($nextUrl) ?>"
hx-trigger="intersect once root:#motscles-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 ($tags as $tag) {
render_motscles_row($tag);
}
render_motscles_sentinel($hasMore, $nextUrl);
return;
}
// ── Initial load: full wrapper ────────────────────────────────────────────
?>
<div id="motscles-bulk-actions" class="admin-bulk-actions" style="display:none;position:sticky;top:0;z-index:10">
<strong><span id="motscles-selected-count">0</span> mot(s)-clé(s) sélectionné(s)</strong>
@@ -68,32 +146,9 @@ try {
<tr><td colspan="4" class="admin-empty">Aucun mot-clé trouvé.</td></tr>
<?php else: ?>
<?php foreach ($tags as $tag): ?>
<tr>
<td style="width:1%"><input type="checkbox" name="selected_tags[]" value="<?= (int)$tag['id'] ?>" onchange="motsclesUpdateBulk()"></td>
<td id="motscles-name-<?= (int)$tag['id'] ?>" data-name="<?= htmlspecialchars($tag['name']) ?>">
<span class="tag-name-cell"><?= htmlspecialchars($tag['name']) ?></span>
<button type="button" class="admin-icon-btn admin-icon-btn--edit" title="Renommer"
onclick="motsclesStartRename(<?= (int)$tag['id'] ?>)">
<?= icon('pencil-note') ?>
</button>
</td>
<td class="admin-tags-count" style="width:1%;white-space:nowrap"><?= (int)$tag['thesis_count'] ?></td>
<td class="admin-actions-col" style="width:1%">
<div class="admin-actions">
<form method="post" action="actions/tag.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="tag_id" value="<?= (int)$tag['id'] ?>">
<button type="button" class="admin-icon-btn admin-icon-btn--delete" title="Supprimer"
onclick="motsclesConfirmDelete(this, <?= htmlspecialchars(json_encode($tag['name']), ENT_QUOTES) ?>)">
<?= icon('trash') ?>
</button>
</form>
</div>
</td>
</tr>
<?php render_motscles_row($tag); ?>
<?php endforeach; ?>
<?php render_motscles_sentinel($hasMore, $nextUrl); ?>
<?php endif; ?>
</tbody>
</table>
+16
View File
@@ -255,6 +255,22 @@
text-align: center;
}
/* "Load more" sentinel row for infinite-scroll admin tables (contenus).
Sits at the bottom of the <tbody>; htmx replaces it with the next page
of rows when it scrolls into the table's viewport. */
.admin-load-more__cell {
color: var(--text-tertiary);
padding: var(--space-s) var(--space-2xs);
text-align: center;
font-size: var(--step--1);
}
/* Fade the sentinel out once its request is in flight so it does not
visually "stick" while the next page is fetched. */
.admin-load-more.htmx-request .admin-load-more__cell {
opacity: 0.4;
}
/* Identifier column in the thesis table */
.admin-table-id {
color: var(--text-secondary);
+118 -8
View File
@@ -1390,11 +1390,41 @@ class Database
}
/**
* Return all tags with a count of associated (published) theses.
* Paged tag listing used by the admin contenus "load more" fragment.
* Same ordering as getAllTagsWithCount(); $query filters by name prefix.
*/
public function getAllTagsWithCount(): array
public function getTagsPage(string $query = '', int $limit = 100, int $offset = 0): array
{
$stmt = $this->pdo->query('
$query = trim($query);
$sql = '
SELECT tg.id, tg.name,
COUNT(DISTINCT t.id) as thesis_count
FROM tags tg
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
LEFT JOIN theses t ON tt.thesis_id = t.id AND t.deleted_at IS NULL
WHERE tg.deleted_at IS NULL';
$params = [];
if ($query !== '') {
$sql .= ' AND tg.name LIKE ?';
$params[] = $query . '%';
}
$sql .= ' GROUP BY tg.id ORDER BY tg.name COLLATE NOCASE LIMIT ? OFFSET ?';
$params[] = $limit;
$params[] = $offset;
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/**
* Return all tags with a count of associated (published) theses.
*
* @param int|null $limit Optional page size (for htmx infinite scroll).
* @param int $offset Row offset when $limit is set.
*/
public function getAllTagsWithCount(?int $limit = null, int $offset = 0): array
{
$sql = '
SELECT tg.id, tg.name,
COUNT(DISTINCT t.id) as thesis_count
FROM tags tg
@@ -1403,8 +1433,32 @@ class Database
WHERE tg.deleted_at IS NULL
GROUP BY tg.id
ORDER BY tg.name COLLATE NOCASE
');
return $stmt->fetchAll();
';
if ($limit !== null) {
$stmt = $this->pdo->prepare($sql . ' LIMIT ? OFFSET ?');
$stmt->execute([$limit, $offset]);
return $stmt->fetchAll();
}
return $this->pdo->query($sql)->fetchAll();
}
/**
* Count tags matching an optional name prefix (used to know when the
* infinite-scroll page has reached the end).
*/
public function countTagsWithCount(string $query = ''): int
{
$query = trim($query);
if ($query === '') {
return (int) $this->pdo->query(
'SELECT COUNT(*) FROM tags WHERE deleted_at IS NULL'
)->fetchColumn();
}
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM tags WHERE name LIKE ? AND deleted_at IS NULL'
);
$stmt->execute([$query . '%']);
return (int) $stmt->fetchColumn();
}
/**
@@ -1514,11 +1568,14 @@ class Database
/**
* Return all languages with a count of associated theses.
*
* @param int|null $limit Optional page size (for htmx infinite scroll).
* @param int $offset Row offset when $limit is set.
*/
public function getAllLanguagesWithCount(): array
public function getAllLanguagesWithCount(?int $limit = null, int $offset = 0): array
{
// Group by lowercased name to deduplicate, keeping the first id
$stmt = $this->pdo->query('
$sql = '
SELECT MIN(l.id) as id,
UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name,
COUNT(DISTINCT t.id) as thesis_count
@@ -1528,7 +1585,60 @@ class Database
WHERE l.deleted_at IS NULL
GROUP BY LOWER(l.name)
ORDER BY LOWER(MIN(l.name)) COLLATE NOCASE
');
';
if ($limit !== null) {
$stmt = $this->pdo->prepare($sql . ' LIMIT ? OFFSET ?');
$stmt->execute([$limit, $offset]);
return $stmt->fetchAll();
}
return $this->pdo->query($sql)->fetchAll();
}
/**
* Count distinct languages matching an optional name prefix (used to know
* when the infinite-scroll page has reached the end).
*/
public function countLanguagesWithCount(string $query = ''): int
{
$query = trim($query);
if ($query === '') {
return (int) $this->pdo->query(
'SELECT COUNT(DISTINCT LOWER(name)) FROM languages WHERE deleted_at IS NULL'
)->fetchColumn();
}
$stmt = $this->pdo->prepare(
'SELECT COUNT(DISTINCT LOWER(name)) FROM languages WHERE LOWER(name) LIKE LOWER(?) AND deleted_at IS NULL'
);
$stmt->execute([$query . '%']);
return (int) $stmt->fetchColumn();
}
/**
* Paged language listing used by the admin contenus "load more" fragment.
* Same ordering/dedup as getAllLanguagesWithCount(); $query filters by
* name prefix.
*/
public function getLanguagesPage(string $query = '', int $limit = 100, int $offset = 0): array
{
$query = trim($query);
$sql = '
SELECT MIN(l.id) as id,
UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name,
COUNT(DISTINCT t.id) as thesis_count
FROM languages l
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
LEFT JOIN theses t ON tl.thesis_id = t.id AND t.deleted_at IS NULL
WHERE l.deleted_at IS NULL';
$params = [];
if ($query !== '') {
$sql .= ' AND LOWER(l.name) LIKE LOWER(?)';
$params[] = $query . '%';
}
$sql .= ' GROUP BY LOWER(l.name) ORDER BY LOWER(MIN(l.name)) COLLATE NOCASE LIMIT ? OFFSET ?';
$params[] = $limit;
$params[] = $offset;
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}