Compare commits
3 Commits
v1.1.1
...
015-librar
| Author | SHA1 | Date | |
|---|---|---|---|
| 781be909bc | |||
| e5e1acb533 | |||
| c9bfdaf241 |
@@ -1 +1 @@
|
||||
{"feature_directory": "specs/014-r2-cdn-serving"}
|
||||
{"feature_directory":"specs/015-library-pagination"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- SPECKIT START -->
|
||||
For additional context about technologies to be used, project structure,
|
||||
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 -->
|
||||
|
||||
@@ -15,7 +15,7 @@ spec:
|
||||
spec:
|
||||
initContainers:
|
||||
- name: migrate
|
||||
image: git.juggalol.com/juggalol/reactbin-api:v1.1.1
|
||||
image: git.juggalol.com/juggalol/reactbin-api:v1.1.2
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
workingDir: /app
|
||||
envFrom:
|
||||
@@ -26,7 +26,7 @@ spec:
|
||||
runAsUser: 1001
|
||||
containers:
|
||||
- name: api
|
||||
image: git.juggalol.com/juggalol/reactbin-api:v1.1.1
|
||||
image: git.juggalol.com/juggalol/reactbin-api:v1.1.2
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
envFrom:
|
||||
|
||||
@@ -15,7 +15,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: ui
|
||||
image: git.juggalol.com/juggalol/reactbin-ui:v1.1.1
|
||||
image: git.juggalol.com/juggalol/reactbin-ui:v1.1.2
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
livenessProbe:
|
||||
|
||||
34
specs/015-library-pagination/checklists/requirements.md
Normal file
34
specs/015-library-pagination/checklists/requirements.md
Normal 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`.
|
||||
52
specs/015-library-pagination/contracts/pagination-query.md
Normal file
52
specs/015-library-pagination/contracts/pagination-query.md
Normal 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`
|
||||
82
specs/015-library-pagination/plan.md
Normal file
82
specs/015-library-pagination/plan.md
Normal 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)`.
|
||||
41
specs/015-library-pagination/quickstart.md
Normal file
41
specs/015-library-pagination/quickstart.md
Normal 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.
|
||||
33
specs/015-library-pagination/research.md
Normal file
33
specs/015-library-pagination/research.md
Normal 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
|
||||
84
specs/015-library-pagination/spec.md
Normal file
84
specs/015-library-pagination/spec.md
Normal 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.
|
||||
82
specs/015-library-pagination/tasks.md
Normal file
82
specs/015-library-pagination/tasks.md
Normal 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. T001–T002 — 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. T001–T002 (US1) → validate
|
||||
2. T003–T004 (US2) → validate URL state
|
||||
3. T005 (polish) → ship
|
||||
@@ -5,6 +5,7 @@ module.exports = function (config) {
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-firefox-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage'),
|
||||
require('@angular-devkit/build-angular/plugins/karma'),
|
||||
|
||||
57
ui/package-lock.json
generated
57
ui/package-lock.json
generated
@@ -31,6 +31,7 @@
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-firefox-launcher": "^2.1.3",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"prettier": "^3.2.0",
|
||||
@@ -10829,6 +10830,62 @@
|
||||
"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": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-firefox-launcher": "^2.1.3",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"prettier": "^3.2.0",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { LibraryComponent } from './library.component';
|
||||
import { ImageService } from '../services/image.service';
|
||||
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 = {
|
||||
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', () => {
|
||||
@@ -74,14 +83,16 @@ describe('LibraryComponent', () => {
|
||||
|
||||
it('shows error card when error is true', () => {
|
||||
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();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.error-card')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('error card has retry button that calls load()', () => {
|
||||
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();
|
||||
spyOn(fixture.componentInstance, 'load');
|
||||
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"]');
|
||||
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',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
<title>Reactbin</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Reactbin">
|
||||
<meta property="og:description" content="Find your perfect reaction image.">
|
||||
<meta property="og:url" content="https://reactbin.juggalol.com">
|
||||
<meta property="og:image" content="https://cdn.reactbin.juggalol.com/0bdaf046f534a1c913629d7ce8069ce407898c19794527bf842524ff4c0073de">
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Reactbin">
|
||||
<meta name="twitter:description" content="Find your perfect reaction image.">
|
||||
<meta name="twitter:image" content="https://cdn.reactbin.juggalol.com/0bdaf046f534a1c913629d7ce8069ce407898c19794527bf842524ff4c0073de">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
|
||||
|
||||
Reference in New Issue
Block a user