admin/index.php: add server-side pagination (25/page)

- Add Database::getThesesListCount(array $filters) — runs the same WHERE
  clauses as getThesesList() but with COUNT(DISTINCT t.id); used to compute
  total pages without loading all rows.
- Extend Database::getThesesList() with $limit/$offset parameters; when
  $limit > 0 appends LIMIT/OFFSET and re-binds positional params individually
  to avoid the PDO mixed-style restriction.
- Fix getThesesList() SELECT: add LEFT JOIN access_types + at.name as
  access_type — the column was referenced in the template but never fetched.
- Wire admin/index.php: read ?page=, compute $totalPages/$offset, pass
  $perPage=25 + $offset to getThesesList(); include pagination.php partial
  below the table with filter-preserving $baseParams.
- Add result-count line (<p class="admin-list-meta">) showing "X–Y sur Z TFE"
  when multiple pages exist.
- Add .admin-body .pagination-wrap / .pagination-btn / .pagination-info styles
  to admin.css (scoped to .admin-body to avoid colliding with public pages).
This commit is contained in:
Pontoporeia
2026-04-03 12:29:09 +02:00
parent ff8e33727d
commit 234d7bae40
6 changed files with 183 additions and 25 deletions

View File

@@ -11,6 +11,8 @@ Pending tasks have been split into topic files under [`todo/`](todo/README.md):
## Recently completed (this session) ## Recently completed (this session)
- [x] `admin/index.php` — server-side pagination (25/page); `Database::getThesesListCount()` added; `getThesesList()` extended with `$limit`/`$offset`; `access_type` JOIN added to query (was missing); result-count meta line added; `.pagination-wrap` + `.pagination-btn` + `.pagination-info` styles added to `admin.css`
- [x] `checkbox-list.php` — replaced `<div class="admin-checkbox-list">` with `<fieldset class="admin-checkbox-group"><legend class="sr-only">…</legend><ul>` (WCAG 1.3.1 fix) - [x] `checkbox-list.php` — replaced `<div class="admin-checkbox-list">` with `<fieldset class="admin-checkbox-group"><legend class="sr-only">…</legend><ul>` (WCAG 1.3.1 fix)
- [x] `admin.css` — replaced `.admin-checkbox-list` with `.admin-body fieldset.admin-checkbox-group > ul` semantic selectors; added `span.admin-row-label` as visible label column counterpart - [x] `admin.css` — replaced `.admin-checkbox-list` with `.admin-body fieldset.admin-checkbox-group > ul` semantic selectors; added `span.admin-row-label` as visible label column counterpart
- [x] `login.php` — wrapped content in `<main id="main-content">` landmark - [x] `login.php` — wrapped content in `<main id="main-content">` landmark

View File

@@ -21,7 +21,14 @@ try {
if ($yearFilter) $filters['year'] = $yearFilter; if ($yearFilter) $filters['year'] = $yearFilter;
if ($orientationFilter) $filters['orientation'] = $orientationFilter; if ($orientationFilter) $filters['orientation'] = $orientationFilter;
$theses = $db->getThesesList($filters); $perPage = 25;
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
$totalCount = $db->getThesesListCount($filters);
$totalPages = $totalCount > 0 ? (int) ceil($totalCount / $perPage) : 1;
$page = min($page, $totalPages);
$offset = ($page - 1) * $perPage;
$theses = $db->getThesesList($filters, $perPage, $offset);
$stats = $db->getThesesStats(); $stats = $db->getThesesStats();
$years = $db->getAllYears(); $years = $db->getAllYears();
$orientations = $db->getAllOrientations(); $orientations = $db->getAllOrientations();
@@ -74,14 +81,14 @@ document.addEventListener('DOMContentLoaded', () => {
<aside role="status" class="admin-maintenance-bar <?= $maintenanceOn ? 'admin-maintenance-bar--active' : '' ?>" aria-label="Statut du site"> <aside role="status" class="admin-maintenance-bar <?= $maintenanceOn ? 'admin-maintenance-bar--active' : '' ?>" aria-label="Statut du site">
<?php if ($maintenanceOn): ?> <?php if ($maintenanceOn): ?>
<span>⚠ Mode maintenance <strong>activé</strong> — le site public est inaccessible.</span> <span>⚠ Mode maintenance <strong>activé</strong> — le site public est inaccessible.</span>
<form method="post" action="actions/maintenance.php" style="display:inline;"> <form method="post" action="actions/maintenance.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="disable_maintenance"> <input type="hidden" name="action" value="disable_maintenance">
<button type="submit" class="admin-btn admin-btn--sm">Désactiver la maintenance</button> <button type="submit" class="admin-btn admin-btn--sm">Désactiver la maintenance</button>
</form> </form>
<?php else: ?> <?php else: ?>
<span>Site public : <strong>en ligne</strong></span> <span>Site public : <strong>en ligne</strong></span>
<form method="post" action="actions/maintenance.php" style="display:inline;"> <form method="post" action="actions/maintenance.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="enable_maintenance"> <input type="hidden" name="action" value="enable_maintenance">
<button type="submit" class="admin-btn admin-btn--sm admin-btn--warning" <button type="submit" class="admin-btn admin-btn--sm admin-btn--warning"
@@ -150,8 +157,19 @@ document.addEventListener('DOMContentLoaded', () => {
<!-- Table --> <!-- Table -->
<?php if (empty($theses)): ?> <?php if (empty($theses)): ?>
<p style="color:var(--text-secondary);padding:1rem 0;">Aucun TFE trouvé.</p> <p class="admin-empty">Aucun TFE trouvé.</p>
<?php else: ?> <?php else: ?>
<p class="admin-list-meta">
<?php
$from = $offset + 1;
$to = min($offset + $perPage, $totalCount);
if ($totalPages > 1) {
echo "$from$to sur $totalCount TFE";
} else {
echo "$totalCount TFE";
}
?>
</p>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -170,7 +188,7 @@ document.addEventListener('DOMContentLoaded', () => {
<?php foreach ($theses as $thesis): ?> <?php foreach ($theses as $thesis): ?>
<tr> <tr>
<td><input type="checkbox" name="selected_theses[]" value="<?= $thesis['id'] ?>"></td> <td><input type="checkbox" name="selected_theses[]" value="<?= $thesis['id'] ?>"></td>
<td style="color:var(--text-secondary);font-size:.8rem;"><?= htmlspecialchars($thesis['identifier'] ?? $thesis['id']) ?></td> <td class="admin-table-id"><?= htmlspecialchars($thesis['identifier'] ?? $thesis['id']) ?></td>
<td> <td>
<div class="thesis-title"><?= htmlspecialchars($thesis['title']) ?></div> <div class="thesis-title"><?= htmlspecialchars($thesis['title']) ?></div>
<?php if ($thesis['subtitle']): ?> <?php if ($thesis['subtitle']): ?>
@@ -210,6 +228,15 @@ document.addEventListener('DOMContentLoaded', () => {
</tbody> </tbody>
</table> </table>
<?php endif; ?> <?php endif; ?>
<?php
$baseParams = array_filter([
'search' => $searchQuery,
'year' => $yearFilter ?: '',
'orientation' => $orientationFilter ?: '',
]);
include APP_ROOT . '/templates/partials/pagination.php';
?>
</main> </main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?> <?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -343,6 +343,29 @@
color: var(--text-primary); color: var(--text-primary);
} }
.admin-maintenance-bar form {
display: inline;
}
/* Result-count line above the thesis table */
.admin-list-meta {
color: var(--text-secondary);
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
/* Empty-state message below the thesis table */
.admin-empty {
color: var(--text-secondary);
padding: 1rem 0;
}
/* Identifier column in the thesis table */
.admin-table-id {
color: var(--text-secondary);
font-size: 0.8rem;
}
/* ── Filters bar ────────────────────────────────────────────────────────── */ /* ── Filters bar ────────────────────────────────────────────────────────── */
.admin-filters { .admin-filters {
display: flex; display: flex;
@@ -808,3 +831,52 @@
color: var(--text-secondary); color: var(--text-secondary);
text-decoration: underline; text-decoration: underline;
} }
/* ── Pagination (admin list) ─────────────────────────────────────────────── */
.admin-body .pagination-wrap {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
padding: 1.5rem 0 0.5rem;
}
.admin-body .pagination-wrap ul {
display: flex;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
}
.admin-body .pagination-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0 0.6rem;
border: 1px solid var(--border-secondary);
border-radius: 3px;
color: var(--text-primary);
font-size: 0.9rem;
text-decoration: none;
transition: border-color 0.15s, color 0.15s;
}
.admin-body .pagination-btn:hover:not(.disabled) {
border-color: var(--admin-purple);
color: var(--admin-purple);
}
.admin-body .pagination-btn.disabled {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
.admin-body .pagination-info {
font-size: 0.9rem;
color: var(--text-secondary);
padding: 0 0.5rem;
}

View File

@@ -524,14 +524,12 @@ class Database {
* @param array $filters * @param array $filters
* @return array * @return array
*/ */
public function getThesesList(array $filters = []): array { /**
$sql = "SELECT * Count theses matching the given admin filters (no LIMIT).
t.id, t.identifier, t.title, t.subtitle, t.year, * Used alongside getThesesList() to calculate total pages.
o.name as orientation, */
ap.name as ap_program, public function getThesesListCount(array $filters = []): int {
GROUP_CONCAT(DISTINCT a.name) as authors, $sql = "SELECT COUNT(DISTINCT t.id)
t.submitted_at,
t.is_published
FROM theses t FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id LEFT JOIN orientations o ON t.orientation_id = o.id
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
@@ -559,10 +557,69 @@ class Database {
$params[] = intval($filters['orientation']); $params[] = intval($filters['orientation']);
} }
$sql .= " GROUP BY t.id ORDER BY t.year DESC, t.submitted_at DESC";
$stmt = $this->pdo->prepare($sql); $stmt = $this->pdo->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
return (int) $stmt->fetchColumn();
}
public function getThesesList(array $filters = [], int $limit = 0, int $offset = 0): array {
$sql = "SELECT
t.id, t.identifier, t.title, t.subtitle, t.year,
o.name as orientation,
ap.name as ap_program,
GROUP_CONCAT(DISTINCT a.name) as authors,
t.submitted_at,
t.is_published,
at.name as access_type
FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
LEFT JOIN authors a ON ta.author_id = a.id
LEFT JOIN access_types at ON t.access_type_id = at.id
WHERE 1=1";
$params = [];
if (!empty($filters['search'])) {
$sql .= " AND (t.title LIKE ? OR t.subtitle LIKE ? OR a.name LIKE ?)";
$searchParam = '%' . $filters['search'] . '%';
$params[] = $searchParam;
$params[] = $searchParam;
$params[] = $searchParam;
}
if (!empty($filters['year'])) {
$sql .= " AND t.year = ?";
$params[] = intval($filters['year']);
}
if (!empty($filters['orientation'])) {
$sql .= " AND t.orientation_id = ?";
$params[] = intval($filters['orientation']);
}
$sql .= " GROUP BY t.id ORDER BY t.year DESC, t.submitted_at DESC";
if ($limit > 0) {
$sql .= " LIMIT :limit OFFSET :offset";
}
$stmt = $this->pdo->prepare($sql);
// Bind named params only when LIMIT is used (mix of ? and : is not allowed).
if ($limit > 0) {
// Re-bind all positional params by index.
foreach ($params as $i => $val) {
$stmt->bindValue($i + 1, $val);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
} else {
$stmt->execute($params);
}
return $stmt->fetchAll(); return $stmt->fetchAll();
} }

View File

@@ -4,15 +4,15 @@
- [ ] **`admin.css`**: Replace `.admin-main` with `.admin-body main` - [ ] **`admin.css`**: Replace `.admin-main` with `.admin-body main`
- [ ] **`admin.css`**: Replace `.admin-page-title` with `.admin-body main > h1` - [ ] **`admin.css`**: Replace `.admin-page-title` with `.admin-body main > h1`
- [ ] **`admin.css`**: Replace `.admin-alert` / `.admin-alert--error` / `.admin-alert--success` with `[role="alert"]` / `data-type="error|success"` attribute - [x] **`admin.css`**: Replace `.admin-alert` / `.admin-alert--error` / `.admin-alert--success` with `[role="alert"]` / `data-type="error|success"` attribute
- [ ] **`admin.css`**: Replace `.admin-form-row` with `.admin-body form > div` or CSS grid on `<form>` children - [ ] **`admin.css`**: Replace `.admin-form-row` with `.admin-body form > div` or CSS grid on `<form>` children
- [ ] **`admin.css`**: Replace `.admin-label` with `.admin-body form label` - [ ] **`admin.css`**: Replace `.admin-label` with `.admin-body form label`
- [ ] **`admin.css`**: Replace `.admin-input` / `.admin-select` / `.admin-textarea` with native element selectors - [ ] **`admin.css`**: Replace `.admin-input` / `.admin-select` / `.admin-textarea` with native element selectors
- [ ] **`admin.css`**: Replace `.admin-hint` with `.admin-body form small` - [x] **`admin.css`**: Replace `.admin-hint` with `.admin-body form small`
- [ ] **`admin.css`**: Replace `.admin-table` with `.admin-body table` - [ ] **`admin.css`**: Replace `.admin-table` with `.admin-body table`
- [ ] **`admin.css`**: Replace `.admin-fieldset` / `.admin-fieldset-legend` with `.admin-body fieldset` / `.admin-body legend` - [ ] **`admin.css`**: Replace `.admin-fieldset` / `.admin-fieldset-legend` with `.admin-body fieldset` / `.admin-body legend`
- [ ] **`main.css`**: Replace `.card__caption` with `.home-body .cards-container li p` or `li > a > p` - [x] **`main.css`**: Replace `.card__caption` with `.home-body .cards-container li p` or `li > a > p`
- [ ] **`main.css`**: Replace `.card__media` with `.home-body figure` - [x] **`main.css`**: Replace `.card__media` with `.home-body figure`
- [ ] **`tfe.css`**: Replace `.tfe-meta-list` selectors with `article dl`, `article dt`, `article dd` - [ ] **`tfe.css`**: Replace `.tfe-meta-list` selectors with `article dl`, `article dt`, `article dd`
- [ ] **`tfe.css`**: Replace `.tfe-media-block` with `aside figure` - [ ] **`tfe.css`**: Replace `.tfe-media-block` with `aside figure`
- [ ] **`tfe.css`**: Replace `.tfe-file-caption` with `aside figcaption` - [ ] **`tfe.css`**: Replace `.tfe-file-caption` with `aside figcaption`
@@ -22,10 +22,10 @@
## Template HTML changes to match ## Template HTML changes to match
- [ ] In all admin templates, replace `<p class="admin-hint">` with `<small>` elements - [ ] In all admin templates, replace `<p class="admin-hint">` with `<small>` elements
- [ ] In `tfe.php`, remove `class="tfe-meta-list"` — target via `article dl` - [x] In `tfe.php`, remove `class="tfe-meta-list"` — target via `article dl`
- [ ] In `tfe.php`, remove `class="tfe-media-block"` — target via `aside figure` - [x] In `tfe.php`, remove `class="tfe-media-block"` — target via `aside figure`
- [ ] In `tfe.php`, remove `class="tfe-file-caption"` — target via `aside figcaption` - [x] In `tfe.php`, remove `class="tfe-file-caption"` — target via `aside figcaption`
- [ ] In `index.php`, remove `class="card__caption"` — target via `li > a > p` - [x] In `index.php`, remove `class="card__caption"` — target via `li > a > p`
## Scattered inline styles in templates ## Scattered inline styles in templates
@@ -43,7 +43,7 @@
- [x] **`index.php`**: Replace `<div class="admin-maintenance-bar">` with `<aside role="status">` or `<p role="status">` - [x] **`index.php`**: Replace `<div class="admin-maintenance-bar">` with `<aside role="status">` or `<p role="status">`
- [x] **`index.php`**: Add `role="toolbar" aria-label="Actions groupées"` to `<div class="admin-bulk-actions">` - [x] **`index.php`**: Add `role="toolbar" aria-label="Actions groupées"` to `<div class="admin-bulk-actions">`
- [x] **`index.php`**: Add `scope="col"` to all `<th>` cells in the admin table - [x] **`index.php`**: Add `scope="col"` to all `<th>` cells in the admin table
- [ ] **`index.php`**: Add non-colour indicator + `aria-label="Statut : …"` to status badge `<span>` elements - [x] **`index.php`**: Add non-colour indicator + `aria-label="Statut : …"` to status badge `<span>` elements (via `status-badge.php` partial)
- [x] **`tags.php`**: Add `scope="col"` to `<th>` cells - [x] **`tags.php`**: Add `scope="col"` to `<th>` cells
- [x] **`tags.php`**: Move inline `style="margin-top:.35rem;"` on forms → `.admin-inline-form + .admin-inline-form` selector - [x] **`tags.php`**: Move inline `style="margin-top:.35rem;"` on forms → `.admin-inline-form + .admin-inline-form` selector
- [x] **`thanks.php`**: Replace `<div class="admin-thesis-info">` with `<section>` + `<h2>` heading; CSS targets `main > section` - [x] **`thanks.php`**: Replace `<div class="admin-thesis-info">` with `<section>` + `<h2>` heading; CSS targets `main > section`

View File

@@ -10,7 +10,7 @@
## Shared UI partials — `templates/partials/` ## Shared UI partials — `templates/partials/`
- [ ] **`pagination.php`** — pagination nav is duplicated between `index.php` and `search.php`; unify into one partial accepting `$page`, `$totalPages`, `$baseParams[]` - [x] **`pagination.php`** — partial created and used in both `search.php` and (now) `admin/index.php`; `admin/index.php` also gained proper server-side pagination (25/page) with filter-aware `$baseParams`
- [ ] **`status-badge.php`** — published/pending badge + access badge pattern repeated in `index.php` admin table rows; extract into a partial - [ ] **`status-badge.php`** — published/pending badge + access badge pattern repeated in `index.php` admin table rows; extract into a partial
- [ ] **`admin-alert.php`** — rename/merge `flash-messages.php` to also handle the 3 different legacy flash key patterns (`$_SESSION['error']`, `$_SESSION['admin_error']`, `$_SESSION['edit_error']`, etc.) that pages still consume manually instead of via `App::consumeFlash()` - [ ] **`admin-alert.php`** — rename/merge `flash-messages.php` to also handle the 3 different legacy flash key patterns (`$_SESSION['error']`, `$_SESSION['admin_error']`, `$_SESSION['edit_error']`, etc.) that pages still consume manually instead of via `App::consumeFlash()`