search: pagination and scroll, add range counter, mobile version

This commit is contained in:
Pontoporeia
2026-07-03 16:53:16 +02:00
parent e607833b2e
commit 880d169e72
8 changed files with 378 additions and 116 deletions
+1 -1
View File
@@ -113,7 +113,7 @@ var editor = new OT(document.getElementById('editor'), {
}
});
JS;
} elseif ($editType === 'apropos') {
} elseif ($editType === 'apropos' || $editType === 'about_page') {
$extraJs = ["/assets/js/app/autosave-handler.js"];
}
+177 -7
View File
@@ -8,6 +8,36 @@
min-height: 0;
overflow: hidden;
padding: 0;
display: flex;
flex-direction: column;
}
/* ── Scrollable results area with sticky pagination ──────────────── */
#search-results {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.search-results-header {
flex-shrink: 0;
}
.search-results-range--compact {
display: none;
}
.search-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-xl) var(--space-m);
color: var(--text-secondary);
font-size: var(--step-0);
text-align: center;
}
/* ---- 6-column index layout ---- */
@@ -510,7 +540,23 @@
justify-content: center;
align-items: center;
gap: var(--space-2xs);
padding: var(--space-m) 0;
padding: var(--space-s) 0;
position: sticky;
bottom: 0;
background: var(--bg-primary);
z-index: 10;
flex-shrink: 0;
margin-top: auto;
border-top: 1px solid var(--border-secondary);
}
.pagination-wrap ul {
display: flex;
align-items: center;
gap: var(--space-2xs);
list-style: none;
margin: 0;
padding: 0;
}
.pagination-btn {
@@ -546,12 +592,6 @@
padding: 0 var(--space-2xs);
}
.search-empty {
padding: var(--space-xl) var(--space-m);
color: var(--text-secondary);
font-size: var(--step-0);
text-align: center;
}
/* Error message */
.search-error {
@@ -675,3 +715,133 @@
color: var(--text-secondary, #888);
line-height: 1.5;
}
/* ── Collapsible filter controls ─────────────────────────────────── */
.search-filters-toggle {
flex-shrink: 0;
border-bottom: 1px solid var(--border-secondary);
}
.search-filters-summary {
display: none; /* hidden on desktop */
align-items: center;
gap: var(--space-2xs);
padding: var(--space-xs) var(--space-m);
font-size: var(--step--2);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
cursor: pointer;
list-style: none;
user-select: none;
min-height: 44px;
}
.search-filters-summary::-webkit-details-marker {
display: none;
}
.search-filters-summary::after {
content: '';
display: inline-block;
width: 8px;
height: 8px;
margin-left: auto;
border-right: 2px solid var(--text-secondary);
border-bottom: 2px solid var(--text-secondary);
transform: rotate(45deg);
transition: transform 0.2s;
}
.search-filters-toggle[open] .search-filters-summary::after {
transform: rotate(-135deg);
}
.search-filters-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.4em;
height: 1.4em;
padding: 0 0.35em;
background: var(--accent-primary);
color: var(--accent-foreground);
border-radius: 99px;
font-size: 0.85em;
font-weight: 500;
letter-spacing: 0;
}
/* ── Responsive: mobile (≤ 768px) ────────────────────────────────── */
@media (max-width: 768px) {
/* Show filter toggle summary, collapse by default if no filters active */
.search-filters-summary {
display: flex;
}
.search-filters-toggle:not([open]) .search-controls {
display: none;
}
.search-filters-toggle[open] .search-controls {
display: flex;
}
/* Filters stack vertically on mobile */
.search-controls {
flex-direction: column;
align-items: stretch;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-m);
}
.search-filter-label {
flex-direction: column;
align-items: stretch;
gap: var(--space-3xs);
}
.search-filter-select {
width: 100%;
}
.search-apply-btn,
.search-reset-link {
width: 100%;
text-align: center;
}
/* Pagination: hide first/last edge buttons */
.pagination-edge {
display: none;
}
.pagination-btn {
min-width: 2.25rem;
min-height: 2.25rem;
font-size: var(--step--2);
}
.pagination-info {
font-size: var(--step--2);
}
/* Reduce results grid gaps and padding */
.results-grid {
gap: var(--space-s);
padding: var(--space-2xs) var(--space-s) var(--space-s);
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.search-results-header {
margin: var(--space-s) var(--space-s) var(--space-2xs);
}
.search-results-range {
display: none;
}
.search-results-range--compact {
display: inline;
}
}
@@ -61,6 +61,9 @@ function handleAutosaveResponse(event) {
}
}
// Expose globally for HTMX hx-on::after-request inlines (bundles wrap in IIFE)
window.handleAutosaveResponse = handleAutosaveResponse;
// Show saving indicator while request is in flight
document.body.addEventListener("htmx:beforeRequest", (e) => {
const url = e.detail.requestConfig?.path || "";
+30 -6
View File
@@ -24,7 +24,7 @@ class SearchController
{
private const RATE_LIMIT_MAX = 300;
private const RATE_LIMIT_WINDOW = 60; // seconds
private const ITEMS_PER_PAGE = 30;
private const ITEMS_PER_PAGE = 15;
private Database $db;
private RateLimit $rateLimit;
@@ -79,6 +79,7 @@ class SearchController
$searchParams = $this->collectSearchParams();
$page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
$offset = ($page - 1) * self::ITEMS_PER_PAGE;
$isHtmx = !empty($_SERVER['HTTP_HX_REQUEST']);
$validationError = null;
$results = [];
@@ -99,11 +100,13 @@ class SearchController
);
$totalItems = $this->db->countSearchResults($searchParams);
$totalPages = (int) ceil($totalItems / self::ITEMS_PER_PAGE);
$years = $this->db->getAvailableYears();
$orientations = $this->db->getAllOrientations();
$apPrograms = $this->db->getAllAPPrograms();
$finalityTypes = $this->db->getAllFinalityTypes();
$formatTypes = $this->db->getAllFormatTypes();
if (!$isHtmx) {
$years = $this->db->getAvailableYears();
$orientations = $this->db->getAllOrientations();
$apPrograms = $this->db->getAllAPPrograms();
$finalityTypes = $this->db->getAllFinalityTypes();
$formatTypes = $this->db->getAllFormatTypes();
}
if (!empty($results)) {
$coverMap = $this->db->getCoverPathsForTheses(array_column($results, 'id'));
}
@@ -119,6 +122,14 @@ class SearchController
$query = $_GET['query'] ?? '';
// HTMX partial: render just the results fragment and exit
if ($isHtmx) {
$this->renderSearchResultsFragment(compact(
'results', 'totalItems', 'totalPages', 'page',
'baseParams', 'coverMap', 'validationError',
));
}
return [
'searchParams' => $searchParams,
'page' => $page,
@@ -154,6 +165,7 @@ class SearchController
],
'currentNav' => 'repertoire',
'extraCss' => ['/assets/dist/repertoire.min.css'],
'extraJs' => ['/assets/js/vendor/htmx.min.js'],
'bodyClass' => 'search-body',
];
}
@@ -212,6 +224,18 @@ class SearchController
// ── Private helpers ───────────────────────────────────────────────────────
/**
* Render the search results fragment and exit (for HTMX pagination).
* Never returns.
*/
private function renderSearchResultsFragment(array $vars): never
{
header('Content-Type: text/html; charset=UTF-8');
extract($vars);
include APP_ROOT . '/templates/partials/search-results.php';
exit();
}
/**
* Render the repertoire index partial and exit (for HTMX swaps).
* Never returns.
+18 -10
View File
@@ -27,34 +27,42 @@ $paginationUrl = static function(int $targetPage) use ($baseParams): string {
$atFirst = $page <= 1;
$atLast = $page >= $totalPages;
/** Build HTMX attributes for a non-disabled pagination link. */
$hxAttrs = static function(string $url): string {
return 'hx-get="' . htmlspecialchars($url) . '"'
. ' hx-target="#search-results"'
. ' hx-swap="outerHTML"'
. ' hx-push-url="' . htmlspecialchars($url) . '"';
};
?>
<nav class="pagination-wrap" aria-label="Pagination">
<ul>
<li>
<a href="<?= $paginationUrl(1) ?>"
<li class="pagination-edge">
<a href="<?= $firstUrl = $paginationUrl(1) ?>"
class="pagination-btn<?= $atFirst ? ' disabled' : '' ?>"
<?= $atFirst ? 'aria-disabled="true" tabindex="-1"' : '' ?>
<?= $atFirst ? 'aria-disabled="true" tabindex="-1"' : $hxAttrs($firstUrl) ?>
aria-label="Première page">«</a>
</li>
<li>
<a href="<?= $paginationUrl(max(1, $page - 1)) ?>"
<a href="<?= $prevUrl = $paginationUrl(max(1, $page - 1)) ?>"
class="pagination-btn<?= $atFirst ? ' disabled' : '' ?>"
<?= $atFirst ? 'aria-disabled="true" tabindex="-1"' : '' ?>
<?= $atFirst ? 'aria-disabled="true" tabindex="-1"' : $hxAttrs($prevUrl) ?>
aria-label="Page précédente"></a>
</li>
<li class="pagination-info" aria-current="page">
<span class="page-current"><?= $page ?></span> / <?= $totalPages ?>
</li>
<li>
<a href="<?= $paginationUrl(min($totalPages, $page + 1)) ?>"
<a href="<?= $nextUrl = $paginationUrl(min($totalPages, $page + 1)) ?>"
class="pagination-btn<?= $atLast ? ' disabled' : '' ?>"
<?= $atLast ? 'aria-disabled="true" tabindex="-1"' : '' ?>
<?= $atLast ? 'aria-disabled="true" tabindex="-1"' : $hxAttrs($nextUrl) ?>
aria-label="Page suivante"></a>
</li>
<li>
<a href="<?= $paginationUrl($totalPages) ?>"
<li class="pagination-edge">
<a href="<?= $lastUrl = $paginationUrl($totalPages) ?>"
class="pagination-btn<?= $atLast ? ' disabled' : '' ?>"
<?= $atLast ? 'aria-disabled="true" tabindex="-1"' : '' ?>
<?= $atLast ? 'aria-disabled="true" tabindex="-1"' : $hxAttrs($lastUrl) ?>
aria-label="Dernière page">»</a>
</li>
</ul>
+63
View File
@@ -0,0 +1,63 @@
<?php
/**
* Search results fragment
*
* Renders the results header, grid, and pagination.
* Used both for the initial page load and for HTMX pagination swaps.
*
* Required variables:
* array $results
* int $totalItems
* int $totalPages
* int $page
* array $baseParams
* array $coverMap
* ?string $validationError
*/
?>
<div id="search-results">
<?php if ($validationError): ?>
<div class="search-error">⚠ <?= htmlspecialchars($validationError) ?></div>
<?php endif; ?>
<?php
$perPage = 15;
$from = ($page - 1) * $perPage + 1;
$to = min($page * $perPage, $totalItems);
?>
<output class="search-results-header" role="status">
<?php if ($totalItems > 0): ?>
<span class="search-results-range"><?= $from ?><?= $to ?> sur <?= $totalItems ?> résultat<?= $totalItems > 1 ? 's' : '' ?></span>
<span class="search-results-range--compact"><?= $from ?><?= $to ?> / <?= $totalItems ?></span>
<?php else: ?>
<span>0 résultat</span>
<?php endif; ?>
</output>
<?php if (!empty($results)): ?>
<ul class="results-grid">
<?php foreach ($results as $item): ?>
<?php $thumb = $coverMap[$item['id']] ?? null; ?>
<li><a href="/tfe?id=<?= (int)$item['id'] ?>" class="result-card">
<?php if ($thumb): ?>
<figure class="result-card__cover">
<img src="/media?path=<?= urlencode($thumb) ?>"
alt="Couverture — <?= htmlspecialchars($item['title']) ?>"
loading="lazy">
</figure>
<?php else: ?>
<div class="result-card__gradient"></div>
<?php endif; ?>
<span class="result-card__authors"><?= htmlspecialchars($item['authors'] ?? '') ?></span>
<span class="result-card__title"><?= htmlspecialchars($item['title']) ?></span>
<small class="result-card__meta"><?= htmlspecialchars($item['year']) ?><?php if (!empty($item['orientation'])): ?> · <?= htmlspecialchars($item['orientation']) ?><?php endif; ?></small>
</a></li>
<?php endforeach; ?>
</ul>
<?php include APP_ROOT . '/templates/partials/pagination.php'; ?>
<?php else: ?>
<p class="search-empty">Aucun résultat pour cette recherche.</p>
<?php endif; ?>
</div>
+76 -92
View File
@@ -2,105 +2,89 @@
<div class="search-error">⚠ <?= htmlspecialchars($validationError) ?></div>
<?php endif; ?>
<?php
// Count active filters (excluding 'query' and 'page')
$filterKeys = ['year', 'ap_program', 'orientation', 'finality', 'format'];
$activeFilterCount = 0;
foreach ($filterKeys as $k) {
if (!empty($_GET[$k])) $activeFilterCount++;
}
?>
<!-- Filter controls -->
<form class="search-controls" method="GET" action="/search">
<input type="hidden" name="query" value="<?= htmlspecialchars($_GET['query'] ?? '') ?>">
<details class="search-filters-toggle" open>
<summary class="search-filters-summary">
Filtres
<?php if ($activeFilterCount > 0): ?>
<span class="search-filters-badge"><?= $activeFilterCount ?></span>
<?php endif; ?>
</summary>
<form class="search-controls" method="GET" action="/search">
<input type="hidden" name="query" value="<?= htmlspecialchars($_GET['query'] ?? '') ?>">
<label class="search-filter-label" for="filter-year">Année
<select class="search-filter-select" name="year" id="filter-year">
<option value="">Toutes</option>
<?php foreach ($years as $y): ?>
<option value="<?= (int)$y ?>" <?= (isset($_GET['year']) && $_GET['year'] == $y) ? 'selected' : '' ?>>
<?= (int)$y ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-year">Année
<select class="search-filter-select" name="year" id="filter-year">
<option value="">Toutes</option>
<?php foreach ($years as $y): ?>
<option value="<?= (int)$y ?>" <?= (isset($_GET['year']) && $_GET['year'] == $y) ? 'selected' : '' ?>>
<?= (int)$y ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-ap">AP
<select class="search-filter-select" name="ap_program" id="filter-ap">
<option value="">Tous</option>
<?php foreach ($apPrograms as $ap): ?>
<option value="<?= htmlspecialchars($ap['name']) ?>"
<?= (isset($_GET['ap_program']) && $_GET['ap_program'] == $ap['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($ap['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-ap">AP
<select class="search-filter-select" name="ap_program" id="filter-ap">
<option value="">Tous</option>
<?php foreach ($apPrograms as $ap): ?>
<option value="<?= htmlspecialchars($ap['name']) ?>"
<?= (isset($_GET['ap_program']) && $_GET['ap_program'] == $ap['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($ap['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-orientation">Orientation
<select class="search-filter-select" name="orientation" id="filter-orientation">
<option value="">Toutes</option>
<?php foreach ($orientations as $o): ?>
<option value="<?= htmlspecialchars($o['name']) ?>"
<?= (isset($_GET['orientation']) && $_GET['orientation'] == $o['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($o['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-orientation">Orientation
<select class="search-filter-select" name="orientation" id="filter-orientation">
<option value="">Toutes</option>
<?php foreach ($orientations as $o): ?>
<option value="<?= htmlspecialchars($o['name']) ?>"
<?= (isset($_GET['orientation']) && $_GET['orientation'] == $o['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($o['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-finality">Finalité
<select class="search-filter-select" name="finality" id="filter-finality">
<option value="">Toutes</option>
<?php foreach ($finalityTypes as $f): ?>
<option value="<?= htmlspecialchars($f['name']) ?>"
<?= (isset($_GET['finality']) && $_GET['finality'] == $f['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($f['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-finality">Finalité
<select class="search-filter-select" name="finality" id="filter-finality">
<option value="">Toutes</option>
<?php foreach ($finalityTypes as $f): ?>
<option value="<?= htmlspecialchars($f['name']) ?>"
<?= (isset($_GET['finality']) && $_GET['finality'] == $f['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($f['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-format">Format
<select class="search-filter-select" name="format" id="filter-format">
<option value="">Tous</option>
<?php foreach ($formatTypes as $fmt): ?>
<option value="<?= htmlspecialchars($fmt['name']) ?>"
<?= (isset($_GET['format']) && $_GET['format'] == $fmt['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($fmt['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="search-filter-label" for="filter-format">Format
<select class="search-filter-select" name="format" id="filter-format">
<option value="">Tous</option>
<?php foreach ($formatTypes as $fmt): ?>
<option value="<?= htmlspecialchars($fmt['name']) ?>"
<?= (isset($_GET['format']) && $_GET['format'] == $fmt['name']) ? 'selected' : '' ?>>
<?= htmlspecialchars($fmt['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<button type="submit" class="btn btn--primary btn--sm search-apply-btn">Filtrer</button>
<a href="/search?query=<?= urlencode($_GET['query'] ?? '') ?>" class="btn btn--secondary btn--sm search-reset-link">Réinitialiser</a>
</form>
<button type="submit" class="btn btn--primary btn--sm search-apply-btn">Filtrer</button>
<a href="/search?query=<?= urlencode($_GET['query'] ?? '') ?>" class="btn btn--secondary btn--sm search-reset-link">Réinitialiser</a>
</form>
</details>
<main class="search-main" id="main-content">
<output class="search-results-header" role="status"><?= $totalItems ?> résultat<?= $totalItems > 1 ? 's' : '' ?></output>
<?php if (!empty($results)): ?>
<ul class="results-grid">
<?php foreach ($results as $item): ?>
<?php $thumb = $coverMap[$item['id']] ?? null; ?>
<li><a href="/tfe?id=<?= (int)$item['id'] ?>" class="result-card">
<?php if ($thumb): ?>
<figure class="result-card__cover">
<img src="/media?path=<?= urlencode($thumb) ?>"
alt="Couverture — <?= htmlspecialchars($item['title']) ?>"
loading="lazy">
</figure>
<?php else: ?>
<div class="result-card__gradient">
<span class="result-card__gradient-author"><?= htmlspecialchars($item['authors'] ?? '') ?></span>
<span class="result-card__gradient-title"><?= htmlspecialchars($item['title']) ?></span>
</div>
<?php endif; ?>
<?php if ($thumb): ?>
<span class="result-card__authors"><?= htmlspecialchars($item['authors'] ?? '') ?></span>
<?php endif; ?>
<span class="result-card__title"><?= htmlspecialchars($item['title']) ?></span>
<small class="result-card__meta"><?= htmlspecialchars($item['year']) ?><?php if (!empty($item['orientation'])): ?> · <?= htmlspecialchars($item['orientation']) ?><?php endif; ?></small>
</a></li>
<?php endforeach; ?>
</ul>
<?php include APP_ROOT . '/templates/partials/pagination.php'; ?>
<?php else: ?>
<p class="search-empty">Aucun résultat pour cette recherche.</p>
<?php endif; ?>
<?php include APP_ROOT . '/templates/partials/search-results.php'; ?>
</main>