8 Commits

Author SHA1 Message Date
781be909bc 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>
2026-05-09 21:08:42 +00:00
e5e1acb533 Chore: Bump manifests after adding previews 2026-05-09 16:18:50 -04:00
c9bfdaf241 Feat: Add Open Graph and Twitter Card meta tags
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 20:17:35 +00:00
75a1449354 Chore: Bump manifests for v1.1.1 release 2026-05-09 13:55:44 -04:00
68881b30f1 Ops: Add script to test lockout with spoofed X-Forwarded-For headers 2026-05-09 13:54:49 -04:00
9021f4816a Fix: Prefer X-Real-IP over XFF[0] in get_client_ip to close spoof bypass
XFF[0] is attacker-controllable; a crafted X-Forwarded-For header could
attribute login failures to a victim IP, triggering their lockout while
the attacker accumulates none. ingress-nginx sets X-Real-IP via its
realip module using an authoritative CIDR allowlist and overwrites any
client-supplied value, making it spoof-resistant. Fallback to XFF[0]
is retained for defence in depth but now emits a warning if reached.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 17:52:05 +00:00
35d21dafa4 Fix: Strip whitespace from S3_PUBLIC_BASE_URL before building CDN URLs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:35:22 +00:00
34d8c3848b Ops: Bump manifests for v1.1.0 release 2026-05-08 20:25:32 -04:00
22 changed files with 795 additions and 38 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 -->
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 -->

View File

@@ -14,20 +14,30 @@ def get_client_ip(
request: Request,
trusted_networks: list[IPv4Network | IPv6Network],
) -> str:
"""Return the resolved client IP, honouring X-Forwarded-For when the
TCP peer is a trusted upstream proxy. Falls back to the TCP peer address
when no trusted networks are configured or the peer is not in the list."""
"""Return the resolved client IP.
Prefers X-Real-IP over X-Forwarded-For when the TCP peer is a trusted
proxy. ingress-nginx sets X-Real-IP via its realip module using an
authoritative CIDR allowlist; it overwrites any client-supplied value, so
it cannot be spoofed via XFF injection. XFF[0] is the fallback for paths
that lack nginx (none currently exist, but kept for defence in depth).
"""
peer = request.client.host if request.client else "unknown"
if trusted_networks and peer != "unknown":
try:
peer_addr = ipaddress.ip_address(peer)
if any(peer_addr in net for net in trusted_networks):
xff = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
if xff:
return xff
real_ip = request.headers.get("X-Real-IP", "").strip()
if real_ip:
return real_ip
# XFF[0] fallback — warn because this path should not be
# reached in production (nginx always sets X-Real-IP).
xff = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
if xff:
logger.warning(
"X-Real-IP absent from trusted peer %s; falling back to XFF[0]", peer
)
return xff
except ValueError:
pass
return peer

View File

@@ -30,7 +30,7 @@ def _error(detail: str, code: str, status: int):
def _image_to_dict(
image: Image, *, cdn_base: str | None = None, duplicate: bool | None = None
) -> dict[str, Any]:
_base = cdn_base.rstrip("/") if cdn_base else None
_base = cdn_base.strip().rstrip("/") if cdn_base else None
file_url = f"{_base}/{image.storage_key}" if _base else f"/api/v1/images/{image.id}/file"
thumbnail_url = (
(f"{_base}/{image.thumbnail_key}" if _base else f"/api/v1/images/{image.id}/thumbnail")

View File

@@ -80,10 +80,17 @@ def test_get_client_ip_no_trusted_networks_returns_peer():
assert get_client_ip(req, []) == "203.0.113.1"
def test_get_client_ip_trusted_peer_uses_xff():
req = make_request("10.0.0.1", {"X-Forwarded-For": "203.0.113.5"})
def test_get_client_ip_trusted_peer_uses_real_ip():
req = make_request("10.0.0.1", {"X-Real-IP": "203.0.113.9"})
nets = [ipaddress.ip_network("10.0.0.0/8")]
assert get_client_ip(req, nets) == "203.0.113.5"
assert get_client_ip(req, nets) == "203.0.113.9"
def test_get_client_ip_real_ip_wins_over_xff():
# Regression: spoofed XFF must not override nginx-set X-Real-IP.
req = make_request("10.0.0.1", {"X-Real-IP": "203.0.113.9", "X-Forwarded-For": "1.2.3.4"})
nets = [ipaddress.ip_network("10.0.0.0/8")]
assert get_client_ip(req, nets) == "203.0.113.9"
def test_get_client_ip_untrusted_peer_ignores_xff():
@@ -92,7 +99,7 @@ def test_get_client_ip_untrusted_peer_ignores_xff():
assert get_client_ip(req, nets) == "8.8.8.8"
def test_get_client_ip_trusted_peer_falls_back_to_real_ip():
req = make_request("10.0.0.1", {"X-Real-IP": "203.0.113.9"})
def test_get_client_ip_trusted_peer_falls_back_to_xff_when_no_real_ip():
req = make_request("10.0.0.1", {"X-Forwarded-For": "203.0.113.5"})
nets = [ipaddress.ip_network("10.0.0.0/8")]
assert get_client_ip(req, nets) == "203.0.113.9"
assert get_client_ip(req, nets) == "203.0.113.5"

View File

@@ -56,3 +56,10 @@ def test_cdn_trailing_slash_normalised():
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
assert result["thumbnail_url"] == "https://cdn.example.com/abc123storagekey-thumb"
assert "//" not in result["file_url"].replace("https://", "")
def test_cdn_trailing_whitespace_normalised():
img = _make_image(thumbnail_key="abc123storagekey-thumb")
result = _image_to_dict(img, cdn_base="https://cdn.example.com ")
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
assert result["thumbnail_url"] == "https://cdn.example.com/abc123storagekey-thumb"

View File

@@ -15,7 +15,7 @@ spec:
spec:
initContainers:
- name: migrate
image: git.juggalol.com/juggalol/reactbin-api:v1.0.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.0.1
image: git.juggalol.com/juggalol/reactbin-api:v1.1.2
ports:
- containerPort: 8000
envFrom:

View File

@@ -15,7 +15,7 @@ spec:
spec:
containers:
- name: ui
image: git.juggalol.com/juggalol/reactbin-ui:v1.0.1
image: git.juggalol.com/juggalol/reactbin-ui:v1.1.2
ports:
- containerPort: 8080
livenessProbe:

67
scripts/test_lockout.sh Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
#
# Test reactbin's login rate limiter and demonstrate the XFF injection bypass.
#
# Phase 1: Send 6 bad login attempts in quick succession.
# Attempts 1-5 should return 401 (invalid credentials).
# Attempt 6 should return 429 (rate limited) — the limiter blocks after
# max_failures=5 within the window.
#
# Phase 2: Send a 7th bad attempt with a spoofed X-Forwarded-For header
# pointing at a different IP. If the lockout keys correctly on the trusted
# client IP, this should still return 429 (same client, still locked).
# If reactbin trusts client-supplied XFF blindly, this would return 401
# instead — the spoof would make the request look like a different client
# that hasn't accumulated failures.
#
# Interpretation:
# - 429 on attempt 7 → lockout is correctly identifying the client
# - 401 on attempt 7 → XFF injection succeeded; server treated us as a
# new client because we set a fake XFF
#
# Note: this script is ONLY useful when run against the public origin path
# where XFF spoofing is potentially possible. It does not exercise the
# Cloudflare-proxied path because Cloudflare strips/replaces XFF before
# forwarding to origin.
set -u
URL="${URL:-https://reactbin.juggalol.com/api/v1/auth/token}"
SPOOFED_IP="${SPOOFED_IP:-198.51.100.99}" # TEST-NET-2, never routed
USERNAME="${USERNAME:-not-a-real-user}"
PASSWORD="${PASSWORD:-not-a-real-password}"
# JSON body for a bad login. Username/password chosen to be obviously fake;
# adjust if your auth provider has its own validation that would 400 instead
# of 401 on these values.
BODY=$(printf '{"username":"%s","password":"%s"}' "$USERNAME" "$PASSWORD")
echo "Target: $URL"
echo "Body: $BODY"
echo
echo "=== Phase 1: 6 bad logins from real client IP ==="
for i in 1 2 3 4 5 6; do
code=$(curl -sS -o /dev/null -w '%{http_code}' \
-X POST \
-H 'Content-Type: application/json' \
--data "$BODY" \
"$URL")
echo "Attempt $i: HTTP $code"
done
echo
echo "=== Phase 2: 7th attempt with spoofed X-Forwarded-For ==="
echo "Setting X-Forwarded-For: $SPOOFED_IP"
code=$(curl -sS -o /dev/null -w '%{http_code}' \
-X POST \
-H 'Content-Type: application/json' \
-H "X-Forwarded-For: $SPOOFED_IP" \
--data "$BODY" \
"$URL")
echo "Attempt 7: HTTP $code"
echo
echo "Interpretation:"
echo " Attempt 7 = 429 → lockout correctly tracks real client; XFF spoof ineffective"
echo " Attempt 7 = 401 → XFF spoof succeeded; server believed the fake client IP"

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: [
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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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',
}));
});
});

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();
}

View File

@@ -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">