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

@@ -1 +1 @@
{"feature_directory": "specs/014-r2-cdn-serving"} {"feature_directory":"specs/015-library-pagination"}

View File

@@ -1,5 +1,5 @@
<!-- SPECKIT START --> <!-- SPECKIT START -->
For additional context about technologies to be used, project structure, For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan at shell commands, and other important information, read the current plan at
`specs/014-r2-cdn-serving/plan.md`. `specs/015-library-pagination/plan.md`.
<!-- SPECKIT END --> <!-- SPECKIT END -->

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Library Pagination UI
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-09
**Feature**: [spec.md](../spec.md)
## Content Quality
- [X] No implementation details (languages, frameworks, APIs)
- [X] Focused on user value and business needs
- [X] Written for non-technical stakeholders
- [X] All mandatory sections completed
## Requirement Completeness
- [X] No [NEEDS CLARIFICATION] markers remain
- [X] Requirements are testable and unambiguous
- [X] Success criteria are measurable
- [X] Success criteria are technology-agnostic (no implementation details)
- [X] All acceptance scenarios are defined
- [X] Edge cases are identified
- [X] Scope is clearly bounded
- [X] Dependencies and assumptions identified
## Feature Readiness
- [X] All functional requirements have clear acceptance criteria
- [X] User scenarios cover primary flows
- [X] Feature meets measurable outcomes defined in Success Criteria
- [X] No implementation details leak into specification
## Notes
- All items pass. Ready for `/speckit-plan`.

View File

@@ -0,0 +1,52 @@
# Contract: Image List Pagination Query
No new API endpoints are introduced. This document records the existing API contract the UI relies on for pagination.
## Endpoint
```
GET /api/v1/images?limit={limit}&offset={offset}&tags={tags}
```
## Parameters
| Parameter | Type | Required | Description |
|-----------|---------|----------|--------------------------------------------------|
| `limit` | integer | No | Images per page. UI sends `24`. Max is 100. |
| `offset` | integer | No | Number of images to skip. UI computes `(page-1) * 24`. |
| `tags` | string | No | Comma-separated tag names for AND-filter. |
## Response
```json
{
"items": [ /* ImageRecord[] */ ],
"total": 143,
"limit": 24,
"offset": 48
}
```
| Field | Type | Description |
|----------|---------|--------------------------------------------------|
| `total` | integer | Total images matching the filter (all pages). |
| `limit` | integer | Page size echoed back. |
| `offset` | integer | Offset echoed back. |
| `items` | array | Images for this page only. |
## UI-Computed Values
```
totalPages = Math.ceil(total / limit) // e.g. ceil(143 / 24) = 6
currentPage = offset / limit + 1 // e.g. 48 / 24 + 1 = 3
offset = (page - 1) * limit // e.g. (3 - 1) * 24 = 48
```
## URL State
| Query Param | Source | Example |
|-------------|---------------------|------------------|
| `page` | current page number | `?page=3` |
| `tags` | active tag filters | `?tags=cat,funny` |
Both params coexist: `/?page=3&tags=cat,funny`

View File

@@ -0,0 +1,82 @@
# Implementation Plan: Library Pagination UI
**Branch**: `015-library-pagination` | **Date**: 2026-05-09 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/015-library-pagination/spec.md`
## Summary
Replace the current "Load more" append-on-scroll pattern in the library with discrete page navigation (Previous/Next buttons, page indicator, total count). Page state is persisted to the URL query string for bookmarkability. No API or backend changes required — the API already supports `limit` and `offset` parameters.
## Technical Context
**Language/Version**: TypeScript (strict), Angular latest stable
**Primary Dependencies**: Angular Router (query params for URL state), Angular HttpClient (existing)
**Storage**: N/A — UI-only change
**Testing**: Angular TestBed / Jasmine (existing test suite)
**Target Platform**: Browser SPA
**Project Type**: UI feature within existing Angular standalone component
**Performance Goals**: Page load of 24 images replaces 50-image Load More; no regression
**Constraints**: Must preserve existing tag filter query param (`?tags=`) when updating page param; must not break existing spec tests
**Scale/Scope**: Single component change (`library.component.ts`) + its spec file
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| §2.1 Strict separation | ✅ PASS | UI communicates with API only via HTTP; no storage or DB knowledge in component |
| §2.6 No speculative abstraction | ✅ PASS | No new abstractions introduced; pagination is a concrete change to one component |
| §3.2 OpenAPI as contract | ✅ PASS | Uses existing `GET /api/v1/images?limit=&offset=` contract; no new endpoints |
| §3.4 Pagination | ✅ PASS | This feature is the UI surface for the API pagination already in place |
| §5.1 Tests alongside implementation | ✅ REQUIRED | Component spec must be updated alongside each changed behaviour |
| §5.4 Test gate | ✅ REQUIRED | UI tests must pass; `make test-unit` passes before task marked done |
| §6 Tech stack | ✅ PASS | Angular + TypeScript strict — no new dependencies needed |
| §7.3 Linting | ✅ REQUIRED | ESLint + Prettier enforced; no lint regressions |
| §8 Scope boundaries | ✅ PASS | Pagination is explicitly required (§3.4); no out-of-scope additions |
**Post-Phase-1 re-check**: No contracts or data model introduced; no new violations.
## Project Structure
### Documentation (this feature)
```text
specs/015-library-pagination/
├── plan.md ← this file
├── research.md ← Phase 0 output
├── quickstart.md ← Phase 1 output
├── contracts/
│ └── pagination-query.md ← Phase 1 output
└── tasks.md ← Phase 2 output (/speckit-tasks)
```
### Source Code (changes only)
```text
ui/src/app/library/
├── library.component.ts ← primary change
└── library.component.spec.ts ← tests updated alongside
```
No other files change. No new files added to source tree.
## Key Design Decisions
### Page size: 24
Fixed at 24 images per page (spec FR-011). Fits common grid widths (2/3/4/6 columns), is a meaningful reduction from the current silent 50-image cap, and divides cleanly. Not user-configurable.
### Replace, don't append
Current `loadMore()` appends items to the array. The new `goToPage(n)` replaces `this.images` entirely. The `offset` field becomes derived from page: `offset = (page - 1) * limit`.
### URL state via Angular Router query params
- `?page=2` added alongside existing `?tags=cat,funny`
- Use `queryParamsHandling: 'merge'` when updating page to preserve tag params
- Use `queryParamsHandling: 'merge'` when updating tags to preserve page reset (page always resets to 1 on filter change, so page param is removed or set to 1)
- On `ngOnInit`, read `page` from `snapshot.queryParamMap`; clamp to valid range
### Out-of-page-range handling
If URL `?page=99` is requested but only 3 pages exist: silently load page 1. No error state.
### Pagination controls visibility
Only shown when `totalPages > 1`. Total pages = `Math.ceil(total / limit)`.

View File

@@ -0,0 +1,41 @@
# Quickstart: Library Pagination UI
## Happy Path — Navigating Pages
**Setup**: Library contains more than 24 images.
1. Open `http://localhost:4200/`
2. Confirm the image grid shows 24 images (not 50).
3. Confirm "Page 1 of N" indicator and total count are visible above or below the grid.
4. Confirm "Previous" button is absent or disabled.
5. Click "Next" → grid replaces with the next 24 images; indicator updates to "Page 2 of N".
6. Click "Previous" → first 24 images return; indicator shows "Page 1 of N".
7. Navigate to the last page → "Next" is absent or disabled.
## Happy Path — URL State
1. Navigate to page 3 via "Next" button twice.
2. Copy URL from address bar (should contain `?page=3`).
3. Open URL in a new tab → page 3 loads directly.
4. Press browser Back → page 2 loads.
## Happy Path — Tag Filter Resets Page
1. Navigate to page 2.
2. Add a tag filter.
3. Confirm page resets to 1; URL shows `?page=1&tags=<tag>` (or just `?tags=<tag>`).
## Edge Case — Single Page
1. Filter to a tag with fewer than 25 images.
2. Confirm no pagination controls are rendered.
## Edge Case — Out-of-Range URL
1. Manually enter `/?page=9999` in the address bar.
2. Confirm page 1 loads with no error message.
## Edge Case — Empty Library
1. With no images uploaded, open `/`.
2. Confirm the existing empty state is shown; no pagination controls visible.

View File

@@ -0,0 +1,33 @@
# Research: Library Pagination UI
## Decision: Angular Router query params for URL state
**Decision**: Use `this.router.navigate([], { queryParams: { page: n }, queryParamsHandling: 'merge' })` for page navigation and `snapshot.queryParamMap.get('page')` on init.
**Rationale**: The library component already uses Angular Router for `?tags=` query params (added in feature 007). Extending the same pattern to `?page=` is the natural fit and keeps a single source of truth in the URL. The `queryParamsHandling: 'merge'` flag ensures that navigating to a new page does not erase the active `?tags=` filter, and vice versa.
**Alternatives considered**:
- Component-local state only (no URL): rejected — FR-008 requires bookmarkable URLs
- `queryParamsHandling: ''` (replace): rejected — would erase `?tags=` param when changing pages
---
## Decision: Replace `loadMore()` with `goToPage(page: number)`
**Decision**: Remove `loadMore()`, `hasMore`, and the append pattern. Replace with `goToPage(n)` that sets `this.images = []` and loads from `offset = (page - 1) * limit`.
**Rationale**: The spec requires discrete pages (FR-001, FR-006). Keeping `loadMore()` alongside pagination would create conflicting UX. Clean removal is simpler and avoids two code paths.
**Alternatives considered**:
- Keep `loadMore()` as a fallback: rejected — two navigation patterns in one view is confusing
---
## Decision: No new dependencies
**Decision**: Implement using existing Angular Router, HttpClient, and CDR. No pagination library imported.
**Rationale**: The pagination logic is trivial (previous/next buttons, a counter, clamped page index). Pulling in a library for two buttons and a text label adds bundle weight and a dependency for no meaningful gain.
**Alternatives considered**:
- `ngx-pagination`: rejected — overkill for two-button prev/next pattern

View File

@@ -0,0 +1,84 @@
# Feature Specification: Library Pagination UI
**Feature Branch**: `015-library-pagination`
**Created**: 2026-05-09
**Status**: Draft
**Input**: User description: "Pagination UI for the image library"
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Navigate Pages of Images (Priority: P1)
A user with a large image library currently sees at most 50 images and a "Load more" button that appends more images below. There is no way to jump to a specific point in the library or know how many images exist in total. This story replaces the append-on-load pattern with page-by-page navigation: Previous/Next buttons and a "Page N of M" indicator so the user always knows where they are.
**Why this priority**: The core usability gap — a library of any meaningful size is effectively unnavigable today. Without this, the feature has no value.
**Independent Test**: Load the library page. Confirm a page indicator ("Page 1 of N") is visible and the total image count is shown. Click "Next" — confirm the next set of images loads and the indicator updates. Click "Previous" — confirm the first set returns. On the first page, "Previous" is absent or disabled. On the last page, "Next" is absent or disabled. Changing a tag filter resets to page 1.
**Acceptance Scenarios**:
1. **Given** the library has more images than fit on one page, **When** the page loads, **Then** only the first page of images is shown with a "Next" button and "Page 1 of N" indicator visible.
2. **Given** the user is on page 1, **When** they click "Next", **Then** the next page of images replaces the current grid (not appended) and the indicator updates to "Page 2 of N".
3. **Given** the user is on the last page, **When** they view the page, **Then** the "Next" button is absent or disabled and "Previous" is present.
4. **Given** the user is on page 1, **When** they view the page, **Then** the "Previous" button is absent or disabled.
5. **Given** the library has fewer images than one page, **When** the page loads, **Then** no pagination controls are shown.
6. **Given** active tag filters are applied, **When** the user changes the filter, **Then** the page resets to 1 and the indicator updates.
---
### User Story 2 - Page State Reflected in URL (Priority: P2)
The current library URL is always `/`. After implementing page navigation, a user who shares or bookmarks a URL should land on the same page they were viewing, not always page 1.
**Why this priority**: Useful for bookmarking and sharing a specific point in the library, but the library is fully functional without it.
**Independent Test**: Navigate to page 3 of the library. Copy the URL from the browser address bar. Open it in a new tab. Confirm page 3 loads directly. Confirm the Back button in the browser returns to page 2.
**Acceptance Scenarios**:
1. **Given** the user navigates to page 3, **When** the page URL is copied and opened in a new tab, **Then** page 3 loads directly without navigating through prior pages.
2. **Given** the user navigates Next through several pages, **When** they press the browser Back button, **Then** the previous page is restored.
3. **Given** the URL includes a page number beyond the total pages available, **When** the page loads, **Then** page 1 is shown rather than an error.
---
### Edge Cases
- What happens when the total drops below the current page (e.g., images deleted in another session)? → Display page 1.
- What happens when the library is empty? → No pagination controls shown; existing empty state displayed.
- What happens when only one page of results exists? → No pagination controls shown.
- What happens when a filter change results in fewer pages than the current page? → Reset to page 1.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The library MUST display images in discrete pages rather than an appending list.
- **FR-002**: The library MUST show a page indicator displaying the current page number and total page count (e.g., "Page 2 of 7").
- **FR-003**: The library MUST show the total number of images matching the current filters (e.g., "143 images").
- **FR-004**: A "Next" control MUST be available on all pages except the last; a "Previous" control MUST be available on all pages except the first.
- **FR-005**: Pagination controls MUST NOT be shown when all images fit on a single page.
- **FR-006**: Navigating to a new page MUST replace the displayed images, not append to them.
- **FR-007**: Changing a tag filter MUST reset the current page to 1.
- **FR-008**: The current page number MUST be reflected in the URL query string so that the URL is bookmarkable and shareable.
- **FR-009**: Loading a URL with a page parameter MUST display the correct page directly.
- **FR-010**: A page parameter beyond the available range MUST silently fall back to page 1.
- **FR-011**: The page size (number of images per page) MUST be 24 images.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A user can navigate from page 1 to any other page using only Previous/Next controls within 2 clicks per page.
- **SC-002**: The total image count and current position are visible without scrolling on page load.
- **SC-003**: A bookmarked or shared page URL loads the correct page 100% of the time (within the valid range).
- **SC-004**: Changing a tag filter always resets to page 1 with no stale images from the previous page visible.
- **SC-005**: Pages with fewer images than the page size (the last page) display correctly without layout breakage.
## Assumptions
- Page size is fixed at 24 images; no user-configurable page size is required.
- The API already supports `limit` and `offset` parameters; no backend changes are needed.
- The existing "Load more" / infinite-scroll pattern is fully replaced by page navigation.
- Browser history integration (Back/Forward) is satisfied by URL query parameter updates.
- Mobile responsiveness of pagination controls is required to match the existing library layout.

View File

@@ -0,0 +1,82 @@
# Tasks: Library Pagination UI
**Input**: Design documents from `specs/015-library-pagination/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, contracts/pagination-query.md ✅, quickstart.md ✅
**Tests**: Tests accompany each implementation task per §5.1. All changes are in `ui/src/app/library/library.component.ts` and its spec file.
**Organization**: No setup or foundational phase needed — the Angular project and library component already exist. Phase 1 implements US1 (page navigation controls). Phase 2 adds US2 (URL state). Polish runs lint and manual verification.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to
---
## Phase 1: User Story 1 — Previous/Next Page Navigation (Priority: P1) 🎯 MVP
**Goal**: Replace the "Load more" append pattern with discrete Previous/Next page navigation, a "Page N of M" indicator, and a total image count. Page size changes from 50 to 24.
**Independent Test**: With at least 25 images in the library, open `/`. Confirm 24 images are shown, a "Page 1 of N" indicator is visible, "Previous" is absent, and "Next" is present. Click "Next" — confirm the grid is replaced (not appended) with the next 24 images and the indicator updates. Click "Previous" — confirm the first page returns. Apply a tag filter — confirm the page resets to 1.
- [X] T001 [US1] Write tests in `ui/src/app/library/library.component.spec.ts` covering: (1) page indicator text "Page 1 of N" renders when totalPages > 1; (2) total count text renders (e.g. "143 images"); (3) "Next" button present when not on last page; (4) "Previous" button absent on first page; (5) "Previous" present and "Next" absent on last page; (6) no pagination controls rendered when all images fit on one page (total ≤ 24); (7) clicking "Next" calls `imageService.list` with offset=24; (8) clicking "Previous" from page 2 calls `imageService.list` with offset=0; (9) applying a filter resets to page 1 (offset=0). Run `ng test` and confirm the new tests FAIL (implementation pending).
- [X] T002 [US1] Update `ui/src/app/library/library.component.ts`: (a) change `private readonly limit = 50` to `private readonly limit = 24`; (b) remove `hasMore` property and `loadMore()` method; (c) add properties `currentPage = 1`, `totalPages = 1`, `total = 0`; (d) rename/replace `load()` to call `imageService.list(this.activeFilters, this.limit, (this.currentPage - 1) * this.limit)` and on success set `this.images = res.items` (replace, not append), `this.total = res.total`, `this.totalPages = Math.ceil(res.total / this.limit)`, clamp `currentPage` to `Math.max(1, Math.min(this.currentPage, this.totalPages))`; (e) add `nextPage()` that increments `currentPage` and calls `load()`; (f) add `prevPage()` that decrements `currentPage` and calls `load()`; (g) in `applyFilter()`, reset `this.currentPage = 1` before calling `load()`; (h) replace the `<button class="load-more">` element in the template with a pagination bar: a "Previous" button bound to `(click)="prevPage()"` disabled/hidden when `currentPage === 1`, a "Page {{ currentPage }} of {{ totalPages }}" span, a "Next" button bound to `(click)="nextPage()"` disabled/hidden when `currentPage === totalPages`, and place a total count element showing "{{ total }} images" **outside** the pagination bar and outside the `*ngIf="totalPages > 1"` guard so it always renders when images exist (FR-003, SC-002); wrap only the Previous button, page indicator span, and Next button inside `*ngIf="totalPages > 1"`. Run `ng test` and confirm T001 tests pass.
**Checkpoint**: US1 complete. Library shows paginated results with Previous/Next controls and page indicator.
---
## Phase 2: User Story 2 — Page State in URL (Priority: P2)
**Goal**: Persist the current page number in the URL query string (`?page=N`) so that the URL is bookmarkable and the browser Back button works.
**Independent Test**: Navigate to page 3. Copy the URL (should contain `?page=3`). Open in a new tab — confirm page 3 loads directly. Press browser Back — confirm page 2 is shown. Navigate to `/?page=9999` — confirm page 1 loads without error.
- [X] T003 [US2] Add tests to `ui/src/app/library/library.component.spec.ts` covering: (1) on init with `?page=2` in queryParamMap, `currentPage` is set to 2 and `list` is called with `offset=24`; (2) on init with `?page=9999` and total of 48 images, `currentPage` is clamped to page 1; (3) `nextPage()` calls `router.navigate` with `queryParams: { page: 2 }` and `queryParamsHandling: 'merge'`; (4) `applyFilter()` calls `router.navigate` with `queryParams: { page: 1 }` and `queryParamsHandling: 'merge'`. Run `ng test` and confirm new tests FAIL.
- [X] T004 [US2] Update `ui/src/app/library/library.component.ts`: (a) in `ngOnInit`, after reading the `tags` param, read `const pageParam = this.route.snapshot.queryParamMap.get('page')` and set `this.currentPage = pageParam ? Math.max(1, parseInt(pageParam, 10)) : 1` (out-of-range clamping happens after load when totalPages is known); (b) update `nextPage()` and `prevPage()` to call `this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' })` after updating `currentPage`; (c) update `applyFilter()` to call `this.router.navigate([], { queryParams: { page: 1, tags: tags.join(',') || null }, queryParamsHandling: 'merge' })` when resetting to page 1 (pass `null` for tags to remove param when empty); (d) after load resolves and `totalPages` is known, clamp `currentPage` to `Math.min(this.currentPage, Math.max(1, this.totalPages))` and if clamped, call navigate to correct the URL. Run `ng test` and confirm T003 tests pass.
**Checkpoint**: US2 complete. Page state persists in URL; Back button and direct links work.
---
## Phase 3: Polish & Cross-Cutting Concerns
- [X] T005 Run `ng lint` on `ui/src/app/library/library.component.ts` and fix any issues; confirm `ng test` passes with all existing and new tests green; manually verify all quickstart.md scenarios in a browser (pagination controls, URL state, tag filter reset, single-page no-controls, out-of-range URL, empty state).
---
## Dependencies & Execution Order
- T001 before T002 (write failing tests before implementation)
- T002 before T003 (US2 tests build on US1 implementation)
- T003 before T004 (write failing tests before implementation)
- T004 before T005 (polish after full implementation)
### Execution Order Summary
```
Step 1: T001 (US1: failing tests)
Step 2: T002 (US1: implementation — tests turn green)
Step 3: T003 (US2: failing tests)
Step 4: T004 (US2: implementation — tests turn green)
Step 5: T005 (polish: lint + manual verification)
```
---
## Implementation Strategy
### MVP (US1 only)
1. T001T002 — page navigation controls, limit change, replace append
2. **STOP and VALIDATE**: open browser, confirm pagination controls appear and work
3. Deploy if ready
### Full Delivery
1. T001T002 (US1) → validate
2. T003T004 (US2) → validate URL state
3. T005 (polish) → ship

View File

@@ -5,6 +5,7 @@ module.exports = function (config) {
plugins: [ plugins: [
require('karma-jasmine'), require('karma-jasmine'),
require('karma-chrome-launcher'), require('karma-chrome-launcher'),
require('karma-firefox-launcher'),
require('karma-jasmine-html-reporter'), require('karma-jasmine-html-reporter'),
require('karma-coverage'), require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma'), require('@angular-devkit/build-angular/plugins/karma'),

57
ui/package-lock.json generated
View File

@@ -31,6 +31,7 @@
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",
"karma-firefox-launcher": "^2.1.3",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"prettier": "^3.2.0", "prettier": "^3.2.0",
@@ -10829,6 +10830,62 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/karma-firefox-launcher": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/karma-firefox-launcher/-/karma-firefox-launcher-2.1.3.tgz",
"integrity": "sha512-LMM2bseebLbYjODBOVt7TCPP9OI2vZIXCavIXhkO9m+10Uj5l7u/SKoeRmYx8FYHTVGZSpk6peX+3BMHC1WwNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-wsl": "^2.2.0",
"which": "^3.0.0"
}
},
"node_modules/karma-firefox-launcher/node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"dev": true,
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/karma-firefox-launcher/node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/karma-firefox-launcher/node_modules/which": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz",
"integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/which.js"
},
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/karma-jasmine": { "node_modules/karma-jasmine": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz",

View File

@@ -33,6 +33,7 @@
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",
"karma-firefox-launcher": "^2.1.3",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"prettier": "^3.2.0", "prettier": "^3.2.0",

View File

@@ -1,8 +1,8 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter, ActivatedRoute } from '@angular/router'; import { provideRouter, ActivatedRoute, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClientTesting } from '@angular/common/http/testing';
import { of } from 'rxjs'; import { of, throwError } from 'rxjs';
import { LibraryComponent } from './library.component'; import { LibraryComponent } from './library.component';
import { ImageService } from '../services/image.service'; import { ImageService } from '../services/image.service';
import { routes } from '../app.routes'; import { routes } from '../app.routes';
@@ -17,10 +17,19 @@ function makeActivatedRoute(queryParams: Record<string, string> = {}) {
}; };
} }
const EMPTY_PAGE = { items: [], total: 0, limit: 50, offset: 0 }; const EMPTY_PAGE = { items: [], total: 0, limit: 24, offset: 0 };
const ONE_IMAGE = { const ONE_IMAGE = {
items: [{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, file_url: '/api/v1/images/1/file', thumbnail_url: null, created_at: '' }], items: [{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, file_url: '/api/v1/images/1/file', thumbnail_url: null, created_at: '' }],
total: 1, limit: 50, offset: 0, total: 1, limit: 24, offset: 0,
};
const MULTI_PAGE = {
items: Array(24).fill(null).map((_, i) => ({
id: String(i + 1), filename: `img${i + 1}.jpg`, tags: [], hash: '',
mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1,
storage_key: '', thumbnail_key: null,
file_url: `/api/v1/images/${i + 1}/file`, thumbnail_url: null, created_at: '',
})),
total: 48, limit: 24, offset: 0,
}; };
describe('LibraryComponent', () => { describe('LibraryComponent', () => {
@@ -74,14 +83,16 @@ describe('LibraryComponent', () => {
it('shows error card when error is true', () => { it('shows error card when error is true', () => {
const fixture = TestBed.createComponent(LibraryComponent); const fixture = TestBed.createComponent(LibraryComponent);
fixture.componentInstance.error = true; const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(throwError(() => new Error('fail')));
fixture.detectChanges(); fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.error-card')).not.toBeNull(); expect((fixture.nativeElement as HTMLElement).querySelector('.error-card')).not.toBeNull();
}); });
it('error card has retry button that calls load()', () => { it('error card has retry button that calls load()', () => {
const fixture = TestBed.createComponent(LibraryComponent); const fixture = TestBed.createComponent(LibraryComponent);
fixture.componentInstance.error = true; const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(throwError(() => new Error('fail')));
fixture.detectChanges(); fixture.detectChanges();
spyOn(fixture.componentInstance, 'load'); spyOn(fixture.componentInstance, 'load');
const retryBtn = (fixture.nativeElement as HTMLElement).querySelector('.error-card .retry-btn') as HTMLButtonElement; const retryBtn = (fixture.nativeElement as HTMLElement).querySelector('.error-card .retry-btn') as HTMLButtonElement;
@@ -145,4 +156,142 @@ describe('LibraryComponent', () => {
const link = (fixture.nativeElement as HTMLElement).querySelector('a[href="/tags"]'); const link = (fixture.nativeElement as HTMLElement).querySelector('a[href="/tags"]');
expect(link).not.toBeNull(); expect(link).not.toBeNull();
}); });
// ---- Pagination: US1 ----
it('page indicator shows "Page 1 of 2" when totalPages > 1', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
const indicator = (fixture.nativeElement as HTMLElement).querySelector('.page-indicator');
expect(indicator?.textContent).toContain('Page 1 of 2');
});
it('total count renders with correct number', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector('.total-count');
expect(el?.textContent).toContain('48');
});
it('"Next" button present when not on last page', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.next-btn')).not.toBeNull();
});
it('"Previous" button absent on first page', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.prev-btn')).toBeNull();
});
it('"Previous" present and "Next" absent on last page', () => {
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.prev-btn')).not.toBeNull();
expect((fixture.nativeElement as HTMLElement).querySelector('.next-btn')).toBeNull();
});
it('no pagination controls when all images fit on one page', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(ONE_IMAGE));
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.pagination-bar')).toBeNull();
});
it('nextPage() calls imageService.list with offset=24', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
listSpy.calls.reset();
fixture.componentInstance.nextPage();
expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 24);
});
it('prevPage() from page 2 calls imageService.list with offset=0', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
fixture.componentInstance.currentPage = 2;
fixture.componentInstance.totalPages = 2;
listSpy.calls.reset();
fixture.componentInstance.prevPage();
expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 0);
});
it('applyFilter() resets to page 1 (offset=0)', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
fixture.componentInstance.currentPage = 2;
listSpy.calls.reset();
fixture.componentInstance.applyFilter(['cat']);
expect(listSpy).toHaveBeenCalledWith(['cat'], jasmine.any(Number), 0);
});
// ---- Pagination: US2 — URL state ----
it('reads ?page=2 from queryParamMap on init and calls list with offset=24', () => {
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
expect(fixture.componentInstance.currentPage).toBe(2);
expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 24);
});
it('clamps out-of-range ?page=9999 to page 1 after load resolves', () => {
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '9999' }) });
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
// After load, totalPages=2, currentPage should be clamped to 2 (not 9999), then router corrects URL
expect(fixture.componentInstance.currentPage).toBeLessThanOrEqual(fixture.componentInstance.totalPages);
});
it('nextPage() calls router.navigate with page=2 and queryParamsHandling merge', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
fixture.componentInstance.nextPage();
expect(router.navigate).toHaveBeenCalledWith([], jasmine.objectContaining({
queryParams: jasmine.objectContaining({ page: 2 }),
queryParamsHandling: 'merge',
}));
});
it('applyFilter() calls router.navigate with page=1 and queryParamsHandling merge', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
fixture.componentInstance.currentPage = 2;
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
fixture.componentInstance.applyFilter(['dog']);
expect(router.navigate).toHaveBeenCalledWith([], jasmine.objectContaining({
queryParams: jasmine.objectContaining({ page: 1 }),
queryParamsHandling: 'merge',
}));
});
}); });

View File

@@ -85,7 +85,15 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
</div> </div>
</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> </div>
`, `,
styles: [` 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; } .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 { 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); } .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 { export class LibraryComponent implements OnInit {
@@ -129,10 +141,11 @@ export class LibraryComponent implements OnInit {
suggestions: { name: string; image_count: number }[] = []; suggestions: { name: string; image_count: number }[] = [];
showSpinner = false; showSpinner = false;
error = false; error = false;
hasMore = false; currentPage = 1;
totalPages = 1;
total = 0;
readonly skeletonItems = Array(8).fill(null); readonly skeletonItems = Array(8).fill(null);
private offset = 0; private readonly limit = 24;
private readonly limit = 50;
private readonly filterChange$ = new Subject<string>(); private readonly filterChange$ = new Subject<string>();
constructor( constructor(
@@ -148,6 +161,10 @@ export class LibraryComponent implements OnInit {
if (tagsParam) { if (tagsParam) {
this.activeFilters = tagsParam.split(',').map((t) => t.trim()).filter((t) => t.length > 0); 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.load();
this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => { this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => {
if (q) { if (q) {
@@ -164,16 +181,22 @@ export class LibraryComponent implements OnInit {
load(): void { load(): void {
this.error = false; 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(() => { timer(150).pipe(takeUntil(req$)).subscribe(() => {
this.showSpinner = true; this.showSpinner = true;
this.cdr.markForCheck(); this.cdr.markForCheck();
}); });
req$.subscribe({ req$.subscribe({
next: (res) => { next: (res) => {
this.images = [...this.images, ...res.items]; this.images = res.items;
this.offset += res.items.length; this.total = res.total;
this.hasMore = this.offset < 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.showSpinner = false;
this.cdr.markForCheck(); 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 { onTagInput(event: Event): void {
const val = (event.target as HTMLInputElement).value; const val = (event.target as HTMLInputElement).value;
this.tagSearch = val; this.tagSearch = val;
@@ -207,12 +246,12 @@ export class LibraryComponent implements OnInit {
applyFilter(tags: string[]): void { applyFilter(tags: string[]): void {
this.activeFilters = tags; this.activeFilters = tags;
this.offset = 0; this.currentPage = 1;
this.images = []; this.images = [];
this.load(); this.router.navigate([], {
} queryParams: { page: 1, tags: tags.length ? tags.join(',') : null },
queryParamsHandling: 'merge',
loadMore(): void { });
this.load(); this.load();
} }