Feat: Replace Load More with Previous/Next pagination in library

Page size changes from 50 to 24. Library now shows discrete page navigation
with a "Page N of M" indicator, total image count, and URL state (?page=N)
so pages are bookmarkable and the browser Back button works. Tag filter
resets to page 1. Out-of-range page params are clamped silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 21:08:42 +00:00
parent e5e1acb533
commit 781be909bc
14 changed files with 677 additions and 22 deletions

View File

@@ -85,7 +85,15 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
</div>
</div>
<button *ngIf="hasMore && !showSpinner && !error" class="load-more" (click)="loadMore()">Load more</button>
<!-- Total count — always visible when images exist -->
<p *ngIf="total > 0 && !showSpinner && !error" class="total-count">{{ total }} images</p>
<!-- Pagination controls — only when more than one page -->
<div *ngIf="totalPages > 1 && !showSpinner && !error" class="pagination-bar">
<button *ngIf="currentPage > 1" class="prev-btn" (click)="prevPage()">← Previous</button>
<span class="page-indicator">Page {{ currentPage }} of {{ totalPages }}</span>
<button *ngIf="currentPage < totalPages" class="next-btn" (click)="nextPage()">Next →</button>
</div>
</div>
`,
styles: [`
@@ -119,7 +127,11 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
.error-card p { color: var(--text-muted); margin-bottom: 16px; }
.retry-btn { padding: 8px 24px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
.retry-btn:hover { border-color: var(--border-focus); }
.load-more { display: block; margin: 24px auto; padding: 10px 32px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; }
.total-count { text-align: center; color: var(--text-muted); font-size: 0.85rem; margin: 16px 0 8px; }
.pagination-bar { display: flex; justify-content: center; align-items: center; gap: 16px; margin: 16px 0 24px; }
.prev-btn, .next-btn { padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
.prev-btn:hover, .next-btn:hover { border-color: var(--border-focus); }
.page-indicator { color: var(--text-muted); font-size: 0.9rem; }
`],
})
export class LibraryComponent implements OnInit {
@@ -129,10 +141,11 @@ export class LibraryComponent implements OnInit {
suggestions: { name: string; image_count: number }[] = [];
showSpinner = false;
error = false;
hasMore = false;
currentPage = 1;
totalPages = 1;
total = 0;
readonly skeletonItems = Array(8).fill(null);
private offset = 0;
private readonly limit = 50;
private readonly limit = 24;
private readonly filterChange$ = new Subject<string>();
constructor(
@@ -148,6 +161,10 @@ export class LibraryComponent implements OnInit {
if (tagsParam) {
this.activeFilters = tagsParam.split(',').map((t) => t.trim()).filter((t) => t.length > 0);
}
const pageParam = this.route.snapshot.queryParamMap.get('page');
if (pageParam) {
this.currentPage = Math.max(1, parseInt(pageParam, 10) || 1);
}
this.load();
this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => {
if (q) {
@@ -164,16 +181,22 @@ export class LibraryComponent implements OnInit {
load(): void {
this.error = false;
const req$ = this.imageService.list(this.activeFilters, this.limit, this.offset).pipe(share());
const offset = (this.currentPage - 1) * this.limit;
const req$ = this.imageService.list(this.activeFilters, this.limit, offset).pipe(share());
timer(150).pipe(takeUntil(req$)).subscribe(() => {
this.showSpinner = true;
this.cdr.markForCheck();
});
req$.subscribe({
next: (res) => {
this.images = [...this.images, ...res.items];
this.offset += res.items.length;
this.hasMore = this.offset < res.total;
this.images = res.items;
this.total = res.total;
this.totalPages = Math.ceil(res.total / this.limit) || 1;
const clamped = Math.max(1, Math.min(this.currentPage, this.totalPages));
if (clamped !== this.currentPage) {
this.currentPage = clamped;
this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' });
}
this.showSpinner = false;
this.cdr.markForCheck();
},
@@ -185,6 +208,22 @@ export class LibraryComponent implements OnInit {
});
}
nextPage(): void {
if (this.currentPage < this.totalPages) {
this.currentPage++;
this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' });
this.load();
}
}
prevPage(): void {
if (this.currentPage > 1) {
this.currentPage--;
this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' });
this.load();
}
}
onTagInput(event: Event): void {
const val = (event.target as HTMLInputElement).value;
this.tagSearch = val;
@@ -207,12 +246,12 @@ export class LibraryComponent implements OnInit {
applyFilter(tags: string[]): void {
this.activeFilters = tags;
this.offset = 0;
this.currentPage = 1;
this.images = [];
this.load();
}
loadMore(): void {
this.router.navigate([], {
queryParams: { page: 1, tags: tags.length ? tags.join(',') : null },
queryParamsHandling: 'merge',
});
this.load();
}