feat: Pagination et recherche des sections admin
Les listes admin (utilisateurs, classes, matières, affectations) chargeaient toutes les données d'un coup, ce qui dégradait l'expérience avec un volume croissant. La pagination côté serveur existait dans la config API Platform mais aucun Provider ne l'exploitait. Cette implémentation ajoute la pagination serveur (30 items/page, max 100) avec recherche textuelle sur toutes les sections, des composants frontend réutilisables (Pagination + SearchInput avec debounce), et la synchronisation URL pour le partage de liens filtrés. Les Query valident leurs paramètres (clamp page/limit, trim search) pour éviter les abus. Les affectations utilisent des lookup maps pour résoudre les noms sans N+1 queries. Les pages admin gèrent les race conditions via AbortController.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
let { currentPage, totalPages, onPageChange }: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
} = $props();
|
||||
|
||||
let clampedPage = $derived(Math.max(1, Math.min(currentPage, Math.max(1, totalPages))));
|
||||
let pages = $derived(computePages(clampedPage, totalPages));
|
||||
|
||||
function computePages(current: number, total: number): (number | '...')[] {
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const result: (number | '...')[] = [1];
|
||||
|
||||
if (current > 3) {
|
||||
result.push('...');
|
||||
}
|
||||
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(total - 1, current + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
result.push(i);
|
||||
}
|
||||
|
||||
if (current < total - 2) {
|
||||
result.push('...');
|
||||
}
|
||||
|
||||
result.push(total);
|
||||
|
||||
return result;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
<button
|
||||
class="pagination-btn"
|
||||
disabled={clampedPage <= 1}
|
||||
onclick={() => onPageChange(clampedPage - 1)}
|
||||
aria-label="Page précédente"
|
||||
>
|
||||
← Précédent
|
||||
</button>
|
||||
|
||||
<div class="pagination-pages">
|
||||
{#each pages as p}
|
||||
{#if p === '...'}
|
||||
<span class="pagination-ellipsis" aria-hidden="true">…</span>
|
||||
{:else}
|
||||
<button
|
||||
class="pagination-page"
|
||||
class:active={p === clampedPage}
|
||||
onclick={() => onPageChange(p as number)}
|
||||
aria-label="Page {p}"
|
||||
aria-current={p === clampedPage ? 'page' : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="pagination-btn"
|
||||
disabled={clampedPage >= totalPages}
|
||||
onclick={() => onPageChange(clampedPage + 1)}
|
||||
aria-label="Page suivante"
|
||||
>
|
||||
Suivant →
|
||||
</button>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.pagination-page {
|
||||
min-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.pagination-page:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.pagination-btn:focus-visible,
|
||||
.pagination-page:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.pagination-page.active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
min-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.pagination {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
let { value = '', onSearch, placeholder = 'Rechercher...', debounceMs = 300 }: {
|
||||
value?: string;
|
||||
onSearch: (value: string) => void;
|
||||
placeholder?: string;
|
||||
debounceMs?: number;
|
||||
} = $props();
|
||||
|
||||
let inputValue = $state(value);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
$effect(() => {
|
||||
inputValue = value;
|
||||
});
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
inputValue = target.value;
|
||||
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
onSearch(inputValue);
|
||||
}, debounceMs);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
inputValue = '';
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
onSearch('');
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
handleClear();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="search-input-wrapper">
|
||||
<svg class="search-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
class="search-input"
|
||||
{placeholder}
|
||||
value={inputValue}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
{#if inputValue}
|
||||
<button
|
||||
class="search-clear"
|
||||
onclick={handleClear}
|
||||
aria-label="Effacer la recherche"
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: #9ca3af;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 2.25rem 0.5rem 2.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
background: white;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Hide browser-default search clear button */
|
||||
.search-input::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
background: #d1d5db;
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user