diff --git a/TODO.md b/TODO.md
index 3969074..ef3b562 100644
--- a/TODO.md
+++ b/TODO.md
@@ -51,6 +51,9 @@
- [x] #fix-desync-deploy-code-exit-23 Fix desync: deploy-code exit-23 'Operation not permitted' — drop -p/-t (destination owned by www-data:xamxam, setgid; SSH user can't chmod), use -rlDz --size-only --context deploy
- [x] #deploy-permissions-sudo-prompt-can-t deploy-permissions sudo prompt can't accept input: ssh -t drops pty when local stdin isn't a TTY. Add scoped NOPASSWD sudo drop-in (deploy/xamxam-fix-permissions.sudoers) + deploy-sudoers recipe and wire into deploy --context deploy
- [x] #fix-raw-markdown-leaking Fix raw markdown leaking into TOC labels on licence/charte/about pages
+- [x] #rewrite-cc2r-checkbox-label Rewrite CC2r checkbox label to 'J'adhère au Collective Commitment to Reuse (CC2r)' (italic, both Libre and Interne branches)
+- [x] #fix-admin-contenus-page [!high] Fix admin contenus page slowdown: langues/mots-clés fragments ship 2.4MB HTML (737 tag rows + 217 lang rows, ~1960 inline SVG icons + per-row CSRF forms). Implement htmx infinite-scroll 'load more' (paged fragments via limit/offset) for both tables.
+- [x] #reduce-contenus-initial-page [!high] Reduce contenus initial page size from 100 to 25 rows per table (payload ~160KB total vs ~534KB)
## Deferred / Blocked
- [ ] #just-setup-backs-a [!medium] just setup backs a stale setup-dev.sh (clones php-live-reload, legacy admin/data/ dirs) — needs rewrite or removal
diff --git a/app/public/admin/contenus-langues-fragment.php b/app/public/admin/contenus-langues-fragment.php
index 905683f..923c142 100644
--- a/app/public/admin/contenus-langues-fragment.php
+++ b/app/public/admin/contenus-langues-fragment.php
@@ -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
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 rows (append mode),
+ * because the sentinel lives inside the existing .
*/
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('Erreur : ' . htmlspecialchars($e->getMessage()) . '
');
}
+
+$hasMore = ($offset + count($languages)) < $total;
+$nextUrl = '/admin/contenus-langues-fragment.php?offset=' . ($offset + LANGUES_PAGE_SIZE)
+ . ($searchQuery !== '' ? '&q=' . urlencode($searchQuery) : '');
+
+/**
+ * Render one for a language row.
+ */
+function render_langue_row(array $lang): void
+{
+ ?>
+
+
+
+ = htmlspecialchars($lang['name']) ?>
+
+ = icon('pencil-note') ?>
+
+
+ = (int)$lang['thesis_count'] ?>
+
+
+
+
+
+
+
+
+ Chargement…
+
+ 0) {
+ foreach ($languages as $lang) {
+ render_langue_row($lang);
+ }
+ render_langues_sentinel($hasMore, $nextUrl);
+ return;
+}
+
+// ── Initial load: full wrapper ────────────────────────────────────────────
?>
0 langue(s) sélectionnée(s)
@@ -68,32 +146,9 @@ try {
Aucune langue trouvée.
-
-
-
- = htmlspecialchars($lang['name']) ?>
-
- = icon('pencil-note') ?>
-
-
- = (int)$lang['thesis_count'] ?>
-
-
-
-
-
-
+
+
diff --git a/app/public/admin/contenus-motscles-fragment.php b/app/public/admin/contenus-motscles-fragment.php
index d9aba9f..b620f8c 100644
--- a/app/public/admin/contenus-motscles-fragment.php
+++ b/app/public/admin/contenus-motscles-fragment.php
@@ -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 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 rows (append mode),
+ * because the sentinel lives inside the existing .
*/
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('Erreur : ' . htmlspecialchars($e->getMessage()) . '
');
}
+
+$hasMore = ($offset + count($tags)) < $total;
+$nextUrl = '/admin/contenus-motscles-fragment.php?offset=' . ($offset + MOTSCLES_PAGE_SIZE)
+ . ($searchQuery !== '' ? '&q=' . urlencode($searchQuery) : '');
+
+/**
+ * Render one for a tag row.
+ */
+function render_motscles_row(array $tag): void
+{
+ ?>
+
+
+
+ = htmlspecialchars($tag['name']) ?>
+
+ = icon('pencil-note') ?>
+
+
+ = (int)$tag['thesis_count'] ?>
+
+
+
+
+
+
+
+
+ Chargement…
+
+ 0) {
+ foreach ($tags as $tag) {
+ render_motscles_row($tag);
+ }
+ render_motscles_sentinel($hasMore, $nextUrl);
+ return;
+}
+
+// ── Initial load: full wrapper ────────────────────────────────────────────
?>
0 mot(s)-clé(s) sélectionné(s)
@@ -68,32 +146,9 @@ try {
Aucun mot-clé trouvé.
-
-
-
- = htmlspecialchars($tag['name']) ?>
-
- = icon('pencil-note') ?>
-
-
- = (int)$tag['thesis_count'] ?>
-
-
-
-
-
-
+
+
diff --git a/app/public/assets/css/admin.css b/app/public/assets/css/admin.css
index 2321201..cfa1f5f 100644
--- a/app/public/assets/css/admin.css
+++ b/app/public/assets/css/admin.css
@@ -255,6 +255,22 @@
text-align: center;
}
+/* "Load more" sentinel row for infinite-scroll admin tables (contenus).
+ Sits at the bottom of the ; 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);
diff --git a/app/src/Database.php b/app/src/Database.php
index f357a57..0c9abe5 100644
--- a/app/src/Database.php
+++ b/app/src/Database.php
@@ -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();
}
diff --git a/tests/phpunit/PagedLanguagesTagsTest.php b/tests/phpunit/PagedLanguagesTagsTest.php
new file mode 100644
index 0000000..2a3e2f0
--- /dev/null
+++ b/tests/phpunit/PagedLanguagesTagsTest.php
@@ -0,0 +1,174 @@
+db = TestDatabase::getInstance();
+ }
+
+ protected function tearDown(): void
+ {
+ // Languages are seeded by the schema and not cleared by resetData(),
+ // so remove the test-specific rows we inserted.
+ TestDatabase::getPDO()
+ ->exec("DELETE FROM languages WHERE name LIKE 'PagLang%'");
+ }
+
+ private function seedTags(array $names): void
+ {
+ $pdo = TestDatabase::getPDO();
+ $stmt = $pdo->prepare('INSERT INTO tags (name) VALUES (?)');
+ foreach ($names as $n) {
+ $stmt->execute([$n]);
+ }
+ }
+
+ private function seedLanguages(array $names): void
+ {
+ $pdo = TestDatabase::getPDO();
+ $stmt = $pdo->prepare('INSERT INTO languages (name) VALUES (?)');
+ foreach ($names as $n) {
+ $stmt->execute([$n]);
+ }
+ }
+
+ // ── Tags ──────────────────────────────────────────────────────────────
+
+ public function testGetTagsPageRespectsLimitAndOffset(): void
+ {
+ $this->seedTags(['alpha', 'bravo', 'charlie', 'delta', 'echo']);
+
+ $page1 = $this->db->getTagsPage('', 2, 0);
+ $page2 = $this->db->getTagsPage('', 2, 2);
+ $page3 = $this->db->getTagsPage('', 2, 4);
+
+ $this->assertSame(['alpha', 'bravo'], array_column($page1, 'name'));
+ $this->assertSame(['charlie', 'delta'], array_column($page2, 'name'));
+ $this->assertSame(['echo'], array_column($page3, 'name'));
+ }
+
+ public function testGetTagsPageFiltersByPrefix(): void
+ {
+ $this->seedTags(['alpha', 'alpine', 'bravo']);
+
+ $rows = $this->db->getTagsPage('al', 100, 0);
+
+ $this->assertSame(['alpha', 'alpine'], array_column($rows, 'name'));
+ }
+
+ public function testCountTagsWithCount(): void
+ {
+ $this->seedTags(['alpha', 'alpine', 'bravo']);
+
+ $this->assertSame(3, $this->db->countTagsWithCount(''));
+ $this->assertSame(2, $this->db->countTagsWithCount('al'));
+ $this->assertSame(0, $this->db->countTagsWithCount('zzz'));
+ }
+
+ public function testCountTagsIgnoresSoftDeleted(): void
+ {
+ $this->seedTags(['alpha', 'bravo']);
+ $id = (int) TestDatabase::getPDO()
+ ->query("SELECT id FROM tags WHERE name = 'bravo'")->fetchColumn();
+ $this->db->deleteTag($id);
+
+ $this->assertSame(1, $this->db->countTagsWithCount(''));
+ $this->assertSame(['alpha'], array_column($this->db->getTagsPage('', 100, 0), 'name'));
+ }
+
+ public function testGetAllTagsWithCountOptionalPaging(): void
+ {
+ $this->seedTags(['alpha', 'bravo', 'charlie']);
+
+ $this->assertCount(3, $this->db->getAllTagsWithCount());
+ $this->assertSame(
+ ['alpha', 'bravo'],
+ array_column($this->db->getAllTagsWithCount(2, 0), 'name')
+ );
+ $this->assertSame(
+ ['charlie'],
+ array_column($this->db->getAllTagsWithCount(2, 2), 'name')
+ );
+ }
+
+ // ── Languages ─────────────────────────────────────────────────────────
+
+ /**
+ * The schema seeds a few languages, so language tests must not assume an
+ * empty table. Query a unique prefix ("PagLang") and account for the
+ * seeded rows in the unpaged assertions.
+ */
+ public function testGetLanguagesPageRespectsLimitAndOffset(): void
+ {
+ $this->seedLanguages(['PagLang A', 'PagLang B', 'PagLang C', 'PagLang D']);
+
+ $this->assertSame(
+ ['PagLang A', 'PagLang B'],
+ array_column($this->db->getLanguagesPage('PagLang', 2, 0), 'name')
+ );
+ $this->assertSame(
+ ['PagLang C', 'PagLang D'],
+ array_column($this->db->getLanguagesPage('PagLang', 2, 2), 'name')
+ );
+ }
+
+ public function testGetLanguagesPageDeduplicatesCaseInsensitively(): void
+ {
+ $this->seedLanguages(['PagLangDup', 'paglanguedup']);
+
+ // Grouped by LOWER(name): two distinct lowercase names.
+ $names = array_column($this->db->getLanguagesPage('PagLang', 100, 0), 'name');
+ $lowered = array_map('strtolower', $names);
+
+ $this->assertSame(['paglangdup', 'paglanguedup'], $lowered);
+ $this->assertSame(2, $this->db->countLanguagesWithCount('PagLang'));
+ }
+
+ public function testGetLanguagesPageFiltersCaseInsensitively(): void
+ {
+ $this->seedLanguages(['PagLangX', 'PagLangY']);
+
+ // Lowercase query must still match the capitalised names.
+ $rows = $this->db->getLanguagesPage('pagl', 100, 0);
+
+ $this->assertSame(['PagLangX', 'PagLangY'], array_column($rows, 'name'));
+ }
+
+ public function testCountLanguagesWithCount(): void
+ {
+ $this->seedLanguages(['PagLangA', 'PagLangB', 'PagLangC']);
+
+ $this->assertSame(3, $this->db->countLanguagesWithCount('PagLang'));
+ $this->assertSame(1, $this->db->countLanguagesWithCount('PagLangA'));
+ $this->assertSame(0, $this->db->countLanguagesWithCount('zzz'));
+ }
+
+ public function testGetAllLanguagesWithCountOptionalPaging(): void
+ {
+ $this->seedLanguages(['PagLangA', 'PagLangB', 'PagLangC']);
+
+ $unpaged = array_map('strtolower', array_column($this->db->getAllLanguagesWithCount(), 'name'));
+ $this->assertContains('paglanga', $unpaged);
+ $this->assertContains('paglangb', $unpaged);
+ $this->assertContains('paglangc', $unpaged);
+
+ $page1 = array_column($this->db->getAllLanguagesWithCount(2, 0), 'name');
+ $page2 = array_column($this->db->getAllLanguagesWithCount(2, 2), 'name');
+ $this->assertCount(2, $page1);
+ $this->assertCount(2, $page2);
+ // Pages must not overlap.
+ $this->assertSame([], array_intersect($page1, $page2));
+ }
+}