Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87eb2703f5 | |||
| bc0f5173c0 | |||
| 309cfce71c | |||
| b094389131 | |||
| 7d49c12ce2 | |||
| 443887ea93 | |||
| e4bfe13072 | |||
| 0a76bb03b5 | |||
| 8cbf1e527a | |||
| a280d8c761 | |||
| 781be909bc | |||
| e5e1acb533 | |||
| c9bfdaf241 | |||
| 75a1449354 | |||
| 68881b30f1 | |||
| 9021f4816a | |||
| 35d21dafa4 |
@@ -1 +1 @@
|
||||
{"feature_directory": "specs/014-r2-cdn-serving"}
|
||||
{"feature_directory":"specs/016-copy-url-toast"}
|
||||
|
||||
@@ -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/016-copy-url-toast/plan.md`.
|
||||
<!-- SPECKIT END -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -88,7 +88,7 @@ class TagRepository:
|
||||
|
||||
query = select(Tag, count_subq.label("image_count"))
|
||||
if prefix:
|
||||
query = query.where(Tag.name.like(f"{prefix}%"))
|
||||
query = query.where(Tag.name.ilike(f"%{prefix}%"))
|
||||
if min_count > 0:
|
||||
query = query.where(count_subq >= min_count)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,7 +15,7 @@ spec:
|
||||
spec:
|
||||
initContainers:
|
||||
- name: migrate
|
||||
image: git.juggalol.com/juggalol/reactbin-api:v1.1.0
|
||||
image: git.juggalol.com/juggalol/reactbin-api:v1.3.1
|
||||
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.0
|
||||
image: git.juggalol.com/juggalol/reactbin-api:v1.3.1
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
envFrom:
|
||||
|
||||
@@ -15,7 +15,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: ui
|
||||
image: git.juggalol.com/juggalol/reactbin-ui:v1.1.0
|
||||
image: git.juggalol.com/juggalol/reactbin-ui:v1.3.1
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
livenessProbe:
|
||||
|
||||
67
scripts/test_lockout.sh
Normal file
67
scripts/test_lockout.sh
Normal 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"
|
||||
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
|
||||
34
specs/016-copy-url-toast/checklists/requirements.md
Normal file
34
specs/016-copy-url-toast/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Copy URL & Toast Notifications
|
||||
|
||||
**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 to proceed to `/speckit-plan`.
|
||||
50
specs/016-copy-url-toast/contracts/toast-service.md
Normal file
50
specs/016-copy-url-toast/contracts/toast-service.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Contract: ToastService
|
||||
|
||||
**Location**: `ui/src/app/services/toast.service.ts`
|
||||
**Provided in**: `root` (singleton)
|
||||
|
||||
## Interface
|
||||
|
||||
```typescript
|
||||
interface Toast {
|
||||
message: string;
|
||||
type: 'success' | 'error';
|
||||
}
|
||||
|
||||
class ToastService {
|
||||
// Observable — emits a Toast when one is active, null when none.
|
||||
readonly current$: Observable<Toast | null>;
|
||||
|
||||
// Show a toast. Replaces any currently-visible toast.
|
||||
// duration defaults to 3000ms.
|
||||
show(message: string, type?: 'success' | 'error', duration?: number): void;
|
||||
}
|
||||
```
|
||||
|
||||
## Behaviour
|
||||
|
||||
- `show()` emits the toast immediately on `current$`.
|
||||
- After `duration` ms, emits `null` to dismiss.
|
||||
- Calling `show()` again before the timer expires resets the timer (new toast replaces old).
|
||||
- `type` defaults to `'success'`.
|
||||
- `duration` defaults to `3000`.
|
||||
|
||||
## Usage Example
|
||||
|
||||
```typescript
|
||||
// In any component:
|
||||
constructor(private toast: ToastService) {}
|
||||
|
||||
async copyUrl() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
this.toast.show('URL copied!');
|
||||
} catch {
|
||||
this.toast.show('Failed to copy URL', 'error');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Consumer: ToastComponent
|
||||
|
||||
`ToastComponent` subscribes to `current$` via the `async` pipe and renders/hides based on the emitted value. It is placed once in `AppComponent` and is always present in the DOM.
|
||||
74
specs/016-copy-url-toast/plan.md
Normal file
74
specs/016-copy-url-toast/plan.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Implementation Plan: Copy URL & Toast Notifications
|
||||
|
||||
**Branch**: `016-copy-url-toast` | **Date**: 2026-05-09 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/016-copy-url-toast/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add a "Copy URL" button to the image detail page that copies the image's direct file URL to the clipboard, with a reusable toast notification service wired to confirm success or failure. All changes are UI-only; no API changes are required.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript (strict mode), Angular latest stable
|
||||
**Primary Dependencies**: Angular (`@angular/core`, `@angular/common`), RxJS (`BehaviorSubject`), browser Clipboard API (`navigator.clipboard.writeText`)
|
||||
**Storage**: N/A
|
||||
**Testing**: Karma/Jasmine (`ng test`)
|
||||
**Target Platform**: Browser (modern; Clipboard API requires HTTPS — already in place)
|
||||
**Project Type**: Angular standalone SPA
|
||||
**Performance Goals**: Copy action completes in < 100ms perceived latency; toast appears within 300ms of action
|
||||
**Constraints**: TypeScript strict mode, `ChangeDetectionStrategy.OnPush` on all components, no new npm dependencies
|
||||
**Scale/Scope**: Two new files (service + component), two modified files (detail + app component)
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| §2.1 Strict separation of concerns | ✓ PASS | Pure UI change; no API knowledge in UI beyond what's already in `ImageRecord.file_url` |
|
||||
| §2.6 No speculative abstraction | ✓ PASS | Toast service is justified: used immediately by this feature and explicitly planned for reuse (upload confirmation, delete confirmation, filter feedback). Two concrete use cases exist. |
|
||||
| §5.1 Tests alongside implementation | ✓ PASS | Tests required for `ToastService` and the copy button on `DetailComponent` |
|
||||
| §5.2 Test pyramid | ✓ PASS | Unit tests only (no API/DB involved); Karma/Jasmine |
|
||||
| §6 Tech stack | ✓ PASS | Angular, TypeScript strict — no new dependencies |
|
||||
| §7.3 Linting | ✓ PASS | `ng lint` must pass before task is done |
|
||||
| §8 Scope boundaries | ✓ PASS | No multi-user, no embeds, no public sharing infrastructure — just a clipboard copy |
|
||||
|
||||
**Post-design re-check**: No violations. Feature is entirely additive.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/016-copy-url-toast/
|
||||
├── plan.md ← this file
|
||||
├── research.md ← Phase 0 output
|
||||
├── quickstart.md ← Phase 1 output
|
||||
├── contracts/
|
||||
│ └── toast-service.md ← Phase 1 output
|
||||
└── tasks.md ← /speckit-tasks output
|
||||
```
|
||||
|
||||
### Source Code
|
||||
|
||||
```text
|
||||
ui/src/app/
|
||||
├── app.component.ts ← modified: add <app-toast> to template
|
||||
├── services/
|
||||
│ └── toast.service.ts ← new: singleton toast service
|
||||
├── toast/
|
||||
│ └── toast.component.ts ← new: toast display component
|
||||
└── detail/
|
||||
└── detail.component.ts ← modified: add Copy URL button + inject ToastService
|
||||
|
||||
ui/src/app/services/
|
||||
toast.service.spec.ts ← new: unit tests for ToastService
|
||||
|
||||
ui/src/app/toast/
|
||||
toast.component.spec.ts ← new: unit tests for ToastComponent
|
||||
|
||||
ui/src/app/detail/
|
||||
detail.component.spec.ts ← modified: tests for copy button behaviour
|
||||
```
|
||||
|
||||
**Structure Decision**: Single-project Angular SPA. Toast service lives in `services/` alongside `ImageService` and `TagService`. Toast component gets its own `toast/` directory following the existing component-per-directory convention.
|
||||
33
specs/016-copy-url-toast/quickstart.md
Normal file
33
specs/016-copy-url-toast/quickstart.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Quickstart: Copy URL & Toast Notifications
|
||||
|
||||
## Happy Path — Copy URL
|
||||
|
||||
1. Open any image detail page (e.g. `http://localhost:4200/images/{id}`).
|
||||
2. Confirm a "Copy URL" button is visible.
|
||||
3. Click "Copy URL".
|
||||
4. Confirm a success toast appears ("URL copied!" or similar) and then disappears automatically.
|
||||
5. Paste into a text editor — confirm the pasted value is the full image file URL.
|
||||
|
||||
## Happy Path — Toast Auto-Dismiss
|
||||
|
||||
1. Click "Copy URL".
|
||||
2. Confirm the toast appears.
|
||||
3. Do not interact — wait ~3 seconds.
|
||||
4. Confirm the toast disappears on its own.
|
||||
|
||||
## Edge Case — Clipboard Unavailable
|
||||
|
||||
1. In Firefox, navigate to `about:config` and set `dom.events.asyncClipboard.clipboardItem` to `false` (or test with a non-HTTPS localhost where clipboard API may be blocked).
|
||||
2. Click "Copy URL".
|
||||
3. Confirm an error toast appears (e.g. "Failed to copy URL") and auto-dismisses.
|
||||
|
||||
## Edge Case — Rapid Clicks
|
||||
|
||||
1. Click "Copy URL" three times quickly.
|
||||
2. Confirm only one toast is visible at a time (new toast replaces old, no overlapping stack).
|
||||
|
||||
## Regression — Other Pages
|
||||
|
||||
1. Navigate to the library (`/`), upload page (`/upload`), tags page (`/tags`).
|
||||
2. Confirm no toast or copy button is visible on these pages.
|
||||
3. Confirm existing functionality is unaffected.
|
||||
55
specs/016-copy-url-toast/research.md
Normal file
55
specs/016-copy-url-toast/research.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Research: Copy URL & Toast Notifications
|
||||
|
||||
## Decision 1: Toast Service Architecture
|
||||
|
||||
**Decision**: `BehaviorSubject<Toast | null>` singleton service, one active toast at a time — new toasts replace the current one.
|
||||
|
||||
**Rationale**: The simplest approach that satisfies FR-007 (reusable from anywhere) and FR-008 (multiple toasts don't overlap illegibly). A queue adds complexity with no meaningful UX benefit for this app's usage pattern (copy URL, upload confirm, etc. — actions that don't overlap in practice). Replacing the current toast on rapid successive calls is acceptable and visually cleaner than a stack. The `BehaviorSubject` integrates naturally with Angular's `async` pipe and OnPush change detection.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `Subject` (not `BehaviorSubject`): Late subscribers miss toasts that already fired. Rejected — component may subscribe after service emits if change detection is deferred.
|
||||
- Toast queue (array): Adds observable complexity and UI layout decisions. Rejected — over-engineered for this use case.
|
||||
- Angular CDK Overlay: Official but heavy. Pulls in CDK dependency for a feature that needs ~30 lines of code. Rejected per §2.6 (no speculative abstraction) and §6 (no new dependencies).
|
||||
|
||||
---
|
||||
|
||||
## Decision 2: Clipboard API Usage
|
||||
|
||||
**Decision**: `navigator.clipboard.writeText(url)` — no polyfill, no fallback to `document.execCommand`.
|
||||
|
||||
**Rationale**: `execCommand('copy')` is deprecated and removed in some browsers. The Clipboard API is supported in all modern browsers (Chrome 66+, Firefox 63+, Safari 13.1+). The app already requires HTTPS in production (Let's Encrypt via cert-manager), which satisfies the Clipboard API's secure context requirement. On failure (permission denied, API unavailable), catch the rejected Promise and show an error toast.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `execCommand('copy')` fallback: Deprecated, inconsistent, adds code complexity. The failure path (error toast) covers the rare unavailability case more cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Decision 3: What URL to Copy
|
||||
|
||||
**Decision**: Copy `image.file_url` as-is (the direct image file URL).
|
||||
|
||||
**Rationale**: `file_url` is the CDN URL in production (e.g. `https://cdn.reactbin.juggalol.com/…`) — already absolute. In development it is relative (`/api/v1/images/{id}/file`); for dev use, prepend `window.location.origin`. The direct file URL is the right thing to share for a reaction image library: it embeds inline when pasted into Discord/Slack without requiring a click-through.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Detail page URL (`/images/{id}`): The user can already copy this from the browser address bar. The file URL is the value-add.
|
||||
- Always prepend `window.location.origin`: Works for both environments, adds a guard. Included as a defensive measure for the dev case.
|
||||
|
||||
---
|
||||
|
||||
## Decision 4: Toast Positioning
|
||||
|
||||
**Decision**: Fixed position, bottom-center of the viewport.
|
||||
|
||||
**Rationale**: Bottom-center is less intrusive than top-right for a brief confirmation toast. It doesn't overlap the image or the copy button. `pointer-events: none` ensures it never blocks interaction.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Top-right: Common convention (Material, Bootstrap) but overlaps the header/nav area in this layout.
|
||||
- Top-center: Similar issue.
|
||||
|
||||
---
|
||||
|
||||
## Decision 5: OnPush compatibility
|
||||
|
||||
**Decision**: `ToastComponent` uses `ChangeDetectionStrategy.OnPush` with the `async` pipe consuming `toastService.current$`. Angular's `async` pipe calls `markForCheck()` automatically when the observable emits, making it fully compatible with OnPush.
|
||||
|
||||
**Rationale**: Consistent with all other components in the project. No manual `markForCheck()` calls needed in `ToastComponent`.
|
||||
76
specs/016-copy-url-toast/spec.md
Normal file
76
specs/016-copy-url-toast/spec.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Feature Specification: Copy URL & Toast Notifications
|
||||
|
||||
**Feature Branch**: `016-copy-url-toast`
|
||||
**Created**: 2026-05-09
|
||||
**Status**: Draft
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Copy Image URL from Detail Page (Priority: P1)
|
||||
|
||||
A user viewing an image on the detail page wants to share the direct link to that image. They click a "Copy URL" button and the image's direct URL is instantly copied to their clipboard, ready to paste anywhere.
|
||||
|
||||
**Why this priority**: This is the core feature and the primary user value. Everything else builds on it.
|
||||
|
||||
**Independent Test**: Open any image detail page. Click the "Copy URL" button. Paste into a text editor — confirm the pasted value is the direct URL to that image.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user is on an image detail page, **When** they click "Copy URL", **Then** the image's direct URL is copied to their clipboard.
|
||||
2. **Given** a user clicks "Copy URL", **When** the copy succeeds, **Then** a confirmation toast appears briefly and disappears on its own.
|
||||
3. **Given** a user clicks "Copy URL", **When** the clipboard is unavailable (e.g. browser denies permission), **Then** a toast appears indicating the copy failed.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Reusable Toast Notification System (Priority: P2)
|
||||
|
||||
Any part of the application can trigger a brief, non-blocking notification (toast) to confirm an action or surface an error. The toast appears, persists for a short time, then disappears automatically without user interaction.
|
||||
|
||||
**Why this priority**: The toast infrastructure is needed by US1 and is designed as a foundation for future features (e.g. upload confirmation, filter saved, delete confirmed).
|
||||
|
||||
**Independent Test**: Trigger a toast programmatically. Confirm it appears with the correct message, then disappears automatically after a few seconds without any user interaction.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a toast is triggered, **When** it appears, **Then** it displays the provided message and is visible above other content.
|
||||
2. **Given** a toast is visible, **When** sufficient time passes, **Then** it disappears automatically without user interaction.
|
||||
3. **Given** multiple toasts are triggered in quick succession, **When** they appear, **Then** they stack or queue without overlapping illegibly.
|
||||
4. **Given** a toast is visible, **When** the user interacts with the rest of the page, **Then** the toast does not block or intercept those interactions.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the clipboard API is not available or permission is denied? → Show an error toast.
|
||||
- What happens if the user clicks "Copy URL" multiple times rapidly? → Each click copies and shows a toast; toasts queue or stack cleanly.
|
||||
- What happens on a very long URL? → URL is copied in full; toast message is fixed (not the URL itself).
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The image detail page MUST display a "Copy URL" button.
|
||||
- **FR-002**: Clicking "Copy URL" MUST copy the image's direct URL to the system clipboard.
|
||||
- **FR-003**: A success toast MUST appear after a successful copy, confirming the action to the user.
|
||||
- **FR-004**: A failure toast MUST appear if the copy cannot be completed (e.g. clipboard permission denied).
|
||||
- **FR-005**: Toasts MUST disappear automatically after a fixed duration without requiring user interaction.
|
||||
- **FR-006**: Toasts MUST NOT block user interaction with the rest of the page.
|
||||
- **FR-007**: The toast system MUST be reusable — any part of the application must be able to trigger a toast with a custom message.
|
||||
- **FR-008**: Multiple toasts triggered in quick succession MUST display without overlapping illegibly.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A user can copy an image URL in a single click with no additional steps.
|
||||
- **SC-002**: Toast confirmation appears within 300ms of the copy action completing.
|
||||
- **SC-003**: Toasts disappear automatically within 5 seconds of appearing.
|
||||
- **SC-004**: The toast system can be triggered from any page or component without modifying the toast component itself.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The image's direct URL is already available on the detail page (it is — currently displayed or derivable from the current route and API response).
|
||||
- Users are on modern browsers with Clipboard API support; graceful degradation covers the failure case via an error toast.
|
||||
- One toast variant is sufficient for v1: a simple text message with success/error styling. No actions, no dismiss button required.
|
||||
- Toast duration of approximately 3 seconds is appropriate (standard convention).
|
||||
- The detail page already exists; this feature adds to it without redesigning it.
|
||||
94
specs/016-copy-url-toast/tasks.md
Normal file
94
specs/016-copy-url-toast/tasks.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Tasks: Copy URL & Toast Notifications
|
||||
|
||||
**Input**: Design documents from `specs/016-copy-url-toast/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, contracts/toast-service.md ✅, quickstart.md ✅
|
||||
|
||||
**Tests**: Tests accompany each implementation task per §5.1. All changes are in `ui/src/app/`.
|
||||
|
||||
**Organization**: No project setup needed — Angular project exists. The toast infrastructure (US2) must be built before the copy URL feature (US1) can use it, so phases follow implementation dependency order rather than spec priority order.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Toast Infrastructure — User Story 2 (Foundational)
|
||||
|
||||
**Goal**: Build the reusable `ToastService` and `ToastComponent` that US1 and all future features depend on.
|
||||
|
||||
**Independent Test**: In the browser console, call `toastService.show('Hello!')` from any page — confirm a toast appears at the bottom of the screen for ~3 seconds then disappears. Confirm no interaction with the rest of the UI is blocked.
|
||||
|
||||
- [X] T001 [US2] Write tests in `ui/src/app/services/toast.service.spec.ts` covering: (1) `show()` emits a `Toast` object on `current$` with the correct `message` and `type`; (2) after the duration elapses, `current$` emits `null`; (3) `type` defaults to `'success'` when not provided; (4) calling `show()` a second time before the first timer fires replaces the active toast. Run `ng test` and confirm new tests FAIL.
|
||||
|
||||
- [X] T002 [US2] Create `ui/src/app/services/toast.service.ts`: (a) define `export interface Toast { message: string; type: 'success' | 'error'; }`; (b) `@Injectable({ providedIn: 'root' })` class with a private `BehaviorSubject<Toast | null>(null)`; (c) expose `readonly current$: Observable<Toast | null>` from the subject; (d) implement `show(message: string, type: 'success' | 'error' = 'success', duration = 3000): void` — emits the toast immediately, then calls `setTimeout(() => this.subject.next(null), duration)` (store the timer handle and `clearTimeout` it at the start of `show()` so rapid calls replace correctly). Run `ng test` and confirm T001 tests pass.
|
||||
|
||||
- [X] T003 [P] [US2] Write tests in `ui/src/app/toast/toast.component.spec.ts` covering: (1) when `ToastService.current$` emits a `{ message: 'Done', type: 'success' }` toast, a `.toast` element is rendered containing "Done"; (2) the element has the CSS class `success`; (3) when type is `'error'`, the element has class `error`; (4) when `current$` emits `null`, no `.toast` element is present. Run `ng test` and confirm new tests FAIL.
|
||||
|
||||
- [X] T004 [US2] Create `ui/src/app/toast/toast.component.ts`: (a) standalone component, `selector: 'app-toast'`, `ChangeDetectionStrategy.OnPush`, imports `[CommonModule]`; (b) inject `ToastService` as public; (c) template: `<div *ngIf="toastService.current$ | async as toast" class="toast" [class.success]="toast.type === 'success'" [class.error]="toast.type === 'error'">{{ toast.message }}</div>`; (d) styles: `.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 10px 20px; border-radius: var(--radius); font-size: 0.9rem; pointer-events: none; z-index: 1000; white-space: nowrap; }` with `.success { background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); }` and `.error { background: var(--danger); color: var(--danger-text); }`. Run `ng test` and confirm T003 tests pass.
|
||||
|
||||
- [X] T005 [US2] Register `ToastComponent` in `ui/src/app/app.component.ts`: add `ToastComponent` to the `imports` array; add `<app-toast></app-toast>` to the template after `<router-outlet />`; add a test to `ui/src/app/app.component.spec.ts` asserting that an `app-toast` element is present in the rendered output. Confirm all existing AppComponent tests still pass and `ng build --configuration development` succeeds.
|
||||
|
||||
**Checkpoint**: Toast infrastructure complete. Any component in the app can now inject `ToastService` and call `show()`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Copy URL Button — User Story 1 (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Add a "Copy URL" button to the image detail page. One click copies the direct image file URL to the clipboard and shows a confirmation toast.
|
||||
|
||||
**Independent Test**: Open any image detail page. Click "Copy URL". Confirm a success toast appears. Paste into a text editor and confirm the pasted value is the full image file URL. Then simulate a clipboard failure (e.g. revoke clipboard permission) and confirm an error toast appears instead.
|
||||
|
||||
- [X] T006 [US1] Write tests in `ui/src/app/detail/detail.component.spec.ts` covering: (1) a "Copy URL" button (`.copy-url-btn`) is present in the DOM when an image is loaded; (2) clicking it calls `navigator.clipboard.writeText` with the image's `file_url` when `file_url` is already absolute (starts with `http`); (3) when `file_url` is relative (starts with `/`), `writeText` is called with `window.location.origin + file_url`; (4) when `writeText` resolves, `toastService.show` is called with a success message; (5) when `writeText` rejects, `toastService.show` is called with an error message and type `'error'`. Spy on `navigator.clipboard.writeText` using `spyOn(navigator.clipboard, 'writeText')` returning `Promise.resolve()` / `Promise.reject()` as appropriate. Run `ng test` and confirm new tests FAIL.
|
||||
|
||||
- [X] T007 [US1] Update `ui/src/app/detail/detail.component.ts`: (a) inject `ToastService` (add to constructor); (b) add `copyUrl(): void` method — resolves the URL as `this.image!.file_url.startsWith('http') ? this.image!.file_url : window.location.origin + this.image!.file_url`, then calls `navigator.clipboard.writeText(url).then(() => this.toastService.show('URL copied!')).catch(() => this.toastService.show('Failed to copy URL', 'error'))`; (c) add a `<button class="copy-url-btn" (click)="copyUrl()">Copy URL</button>` to the template inside the `*ngIf="image && !loading"` block, placed below the image and above the tags section; (d) style: `padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; margin: 12px 0;` with hover `border-color: var(--border-focus)`. Run `ng test` and confirm T006 tests pass.
|
||||
|
||||
**Checkpoint**: US1 complete. Detail page has a working Copy URL button with toast feedback.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T008 Run `ng lint` on all modified and created files in `ui/src/app/`; fix any issues. Confirm `ng test` passes with all new and existing tests green. Manually verify all `quickstart.md` scenarios in a browser: happy path copy, auto-dismiss, clipboard error, rapid clicks, regression on other pages.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- T001 before T002 (write failing tests before service implementation)
|
||||
- T002 before T003/T004 (service must exist for component tests to import it)
|
||||
- T003 before T004 (write failing tests before component implementation)
|
||||
- T004 before T005 (component must exist before registering in app)
|
||||
- T005 before T006 (toast infrastructure must be complete before copy URL tests)
|
||||
- T006 before T007 (write failing tests before detail component changes)
|
||||
- T007 before T008 (implementation before polish)
|
||||
|
||||
### Execution Order Summary
|
||||
|
||||
```
|
||||
Step 1: T001 (US2: failing ToastService tests)
|
||||
Step 2: T002 (US2: ToastService implementation — tests turn green)
|
||||
Step 3: T003 (US2: failing ToastComponent tests) [can parallel with T002 if needed]
|
||||
Step 4: T004 (US2: ToastComponent implementation — tests turn green)
|
||||
Step 5: T005 (US2: wire ToastComponent into AppComponent)
|
||||
Step 6: T006 (US1: failing copy URL tests)
|
||||
Step 7: T007 (US1: copy URL implementation — tests turn green)
|
||||
Step 8: T008 (Polish: lint + manual verification)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (US1 — single story delivers full feature value)
|
||||
|
||||
1. T001–T005 — toast infrastructure
|
||||
2. T006–T007 — copy URL button
|
||||
3. **STOP and VALIDATE**: open browser, click Copy URL, confirm toast, paste to verify URL
|
||||
4. T008 — polish
|
||||
5. Deploy
|
||||
|
||||
### Note on Priority Ordering
|
||||
|
||||
US2 (toast system) is listed as P2 in the spec because it is infrastructure rather than the end-user-visible feature. However it is a hard implementation prerequisite for US1 (P1). Phases follow implementation dependency order: US2 infrastructure is built first, US1 feature consumes it second.
|
||||
@@ -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",
|
||||
|
||||
@@ -83,6 +83,14 @@ describe('AppComponent', () => {
|
||||
expect(link.getAttribute('href')).toBe('/');
|
||||
});
|
||||
|
||||
it('renders app-toast element', () => {
|
||||
authSpy.isAuthenticated.and.returnValue(false);
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const toast = (fixture.nativeElement as HTMLElement).querySelector('app-toast');
|
||||
expect(toast).not.toBeNull();
|
||||
});
|
||||
|
||||
it('header height is 48px', () => {
|
||||
authSpy.isAuthenticated.and.returnValue(true);
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
|
||||
@@ -2,17 +2,19 @@ import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink, RouterOutlet } from '@angular/router';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
import { ToastComponent } from './toast/toast.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, RouterOutlet],
|
||||
imports: [CommonModule, RouterLink, RouterOutlet, ToastComponent],
|
||||
template: `
|
||||
<header class="app-header">
|
||||
<a routerLink="/" class="app-name">Reactbin</a>
|
||||
<button *ngIf="auth.isAuthenticated()" class="logout-btn" (click)="onLogout()">Sign out</button>
|
||||
</header>
|
||||
<router-outlet />
|
||||
<app-toast></app-toast>
|
||||
`,
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
|
||||
@@ -5,6 +5,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { of, throwError, Subject } from 'rxjs';
|
||||
import { DetailComponent } from './detail.component';
|
||||
import { ImageService } from '../services/image.service';
|
||||
import { ToastService } from '../services/toast.service';
|
||||
import { routes } from '../app.routes';
|
||||
|
||||
const MOCK_IMAGE = {
|
||||
@@ -13,6 +14,7 @@ const MOCK_IMAGE = {
|
||||
thumbnail_key: null, file_url: '/api/v1/images/img-1/file', thumbnail_url: null,
|
||||
created_at: '2026-01-01T00:00:00Z', tags: ['cat', 'funny'],
|
||||
};
|
||||
const MOCK_IMAGE_ABS = { ...MOCK_IMAGE, file_url: 'https://cdn.example.com/img-1.jpg' };
|
||||
|
||||
describe('DetailComponent', () => {
|
||||
function setup(imageId = 'img-1', imageResponse = of(MOCK_IMAGE)) {
|
||||
@@ -143,4 +145,52 @@ describe('DetailComponent', () => {
|
||||
fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event);
|
||||
expect(imgEl.src).toBe(before);
|
||||
});
|
||||
|
||||
// ---- Copy URL ----
|
||||
|
||||
it('Copy URL button is present when image is loaded', () => {
|
||||
const { fixture } = setup();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.copy-url-btn')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('copyUrl() calls writeText with file_url when it is already absolute', async () => {
|
||||
const { component } = setup('img-1', of(MOCK_IMAGE_ABS));
|
||||
const toast = TestBed.inject(ToastService);
|
||||
spyOn(toast, 'show');
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
component.copyUrl();
|
||||
await Promise.resolve();
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(MOCK_IMAGE_ABS.file_url);
|
||||
});
|
||||
|
||||
it('copyUrl() prepends window.location.origin when file_url is relative', async () => {
|
||||
const { component } = setup();
|
||||
const toast = TestBed.inject(ToastService);
|
||||
spyOn(toast, 'show');
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
component.copyUrl();
|
||||
await Promise.resolve();
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(window.location.origin + MOCK_IMAGE.file_url);
|
||||
});
|
||||
|
||||
it('copyUrl() calls toast.show with success message on writeText resolve', async () => {
|
||||
const { component } = setup();
|
||||
const toast = TestBed.inject(ToastService);
|
||||
spyOn(toast, 'show');
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
component.copyUrl();
|
||||
await Promise.resolve();
|
||||
expect(toast.show).toHaveBeenCalledWith('URL copied!');
|
||||
});
|
||||
|
||||
it('copyUrl() calls toast.show with error message on writeText reject', async () => {
|
||||
const { component } = setup();
|
||||
const toast = TestBed.inject(ToastService);
|
||||
spyOn(toast, 'show');
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.reject(new Error('denied')));
|
||||
component.copyUrl();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(toast.show).toHaveBeenCalledWith('Failed to copy URL', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { ImageRecord, ImageService } from '../services/image.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { ToastService } from '../services/toast.service';
|
||||
|
||||
const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500"><rect width="800" height="500" fill="%23111"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="48" fill="%23444">🔗</text></svg>`;
|
||||
|
||||
@@ -54,6 +55,8 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
(error)="onImgError($event)"
|
||||
/>
|
||||
|
||||
<button class="copy-url-btn" (click)="copyUrl()">Copy URL</button>
|
||||
|
||||
<section class="tags-section">
|
||||
<h3>Tags</h3>
|
||||
<div class="chips">
|
||||
@@ -139,6 +142,10 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
.add-tag input:focus { outline: none; border-color: var(--border-focus); }
|
||||
.delete-btn { padding: 10px 24px; background: var(--danger); color: var(--danger-text); border: none; border-radius: var(--radius); cursor: pointer; margin-left: auto; }
|
||||
|
||||
/* Copy URL */
|
||||
.copy-url-btn { padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; margin: 12px 0; transition: border-color var(--transition); }
|
||||
.copy-url-btn:hover { border-color: var(--border-focus); }
|
||||
|
||||
/* Delete dialog */
|
||||
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.dialog { background: var(--surface); padding: 32px; border-radius: 10px; text-align: center; }
|
||||
@@ -163,6 +170,7 @@ export class DetailComponent implements OnInit {
|
||||
private route: ActivatedRoute,
|
||||
public router: Router,
|
||||
private cdr: ChangeDetectorRef,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -235,6 +243,16 @@ export class DetailComponent implements OnInit {
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
copyUrl(): void {
|
||||
if (!this.image) return;
|
||||
const url = this.image.file_url.startsWith('http')
|
||||
? this.image.file_url
|
||||
: window.location.origin + this.image.file_url;
|
||||
navigator.clipboard.writeText(url)
|
||||
.then(() => this.toastService.show('URL copied!'))
|
||||
.catch(() => this.toastService.show('Failed to copy URL', 'error'));
|
||||
}
|
||||
|
||||
goBack(): void { this.router.navigate(['/']); }
|
||||
|
||||
onImgError(event: Event): void {
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
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';
|
||||
|
||||
function makeActivatedRoute(queryParams: Record<string, string> = {}) {
|
||||
const paramMap = { get: (key: string) => queryParams[key] ?? null };
|
||||
return {
|
||||
snapshot: {
|
||||
queryParamMap: {
|
||||
get: (key: string) => queryParams[key] ?? null,
|
||||
},
|
||||
},
|
||||
snapshot: { queryParamMap: paramMap },
|
||||
queryParamMap: of(paramMap),
|
||||
};
|
||||
}
|
||||
|
||||
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 +81,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 +154,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',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
|
||||
import { Subject, debounceTime, distinctUntilChanged, share, timer } from 'rxjs';
|
||||
import { Subject, debounceTime, distinctUntilChanged, share, skip, timer } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { ImageRecord, ImageService } from '../services/image.service';
|
||||
import { TagService } from '../services/tag.service';
|
||||
@@ -21,7 +21,7 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
template: `
|
||||
<div class="library">
|
||||
<header>
|
||||
<h1>Reactbin</h1>
|
||||
<h1><a class="home-link" routerLink="/" [queryParams]="{}">Reactbin</a></h1>
|
||||
<div class="header-actions">
|
||||
<a routerLink="/tags" class="tags-link">Browse tags</a>
|
||||
<button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button>
|
||||
@@ -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: [`
|
||||
@@ -110,7 +118,9 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
.image-card:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
|
||||
.image-card img { width: 100%; height: 160px; object-fit: cover; display: block; }
|
||||
.card-skeleton { height: 200px; }
|
||||
.tag-row { padding: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.tag-row { padding: 6px; display: flex; flex-wrap: nowrap; gap: 4px; overflow: hidden; position: relative; }
|
||||
.tag-row::after { content: ''; position: absolute; right: 0; top: 0; bottom: 0; width: 2rem; background: linear-gradient(to right, transparent, var(--surface)); pointer-events: none; }
|
||||
.home-link { color: inherit; text-decoration: none; cursor: pointer; }
|
||||
.empty-state { text-align: center; padding: 60px 0; color: var(--text-muted); }
|
||||
.empty-icon { display: block; font-size: 2rem; margin-bottom: 12px; }
|
||||
.upload-link { display: inline-block; margin-top: 16px; color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||
@@ -119,7 +129,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 +143,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,7 +163,26 @@ 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.route.queryParamMap.pipe(skip(1)).subscribe((params) => {
|
||||
const newPage = params.get('page') ? Math.max(1, parseInt(params.get('page')!, 10) || 1) : 1;
|
||||
const newTagsParam = params.get('tags');
|
||||
const newTags = newTagsParam
|
||||
? newTagsParam.split(',').map((t) => t.trim()).filter((t) => t.length > 0)
|
||||
: [];
|
||||
const pageChanged = newPage !== this.currentPage;
|
||||
const tagsChanged = JSON.stringify(newTags) !== JSON.stringify(this.activeFilters);
|
||||
if (pageChanged || tagsChanged) {
|
||||
this.currentPage = newPage;
|
||||
this.activeFilters = newTags;
|
||||
this.images = [];
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => {
|
||||
if (q) {
|
||||
this.tagService.list(q, 10).subscribe((r) => {
|
||||
@@ -164,16 +198,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 +225,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 +263,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();
|
||||
}
|
||||
|
||||
|
||||
51
ui/src/app/services/toast.service.spec.ts
Normal file
51
ui/src/app/services/toast.service.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { Toast, ToastService } from './toast.service';
|
||||
|
||||
describe('ToastService', () => {
|
||||
let service: ToastService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(ToastService);
|
||||
});
|
||||
|
||||
it('show() emits a toast with correct message and type', (done) => {
|
||||
service.current$.subscribe((toast) => {
|
||||
if (toast) {
|
||||
expect(toast.message).toBe('Hello!');
|
||||
expect(toast.type).toBe('error');
|
||||
done();
|
||||
}
|
||||
});
|
||||
service.show('Hello!', 'error');
|
||||
});
|
||||
|
||||
it('type defaults to success when not provided', (done) => {
|
||||
service.current$.subscribe((toast) => {
|
||||
if (toast) {
|
||||
expect(toast.type).toBe('success');
|
||||
done();
|
||||
}
|
||||
});
|
||||
service.show('Default type');
|
||||
});
|
||||
|
||||
it('current$ emits null after the duration elapses', fakeAsync(() => {
|
||||
const emitted: (string | null)[] = [];
|
||||
service.current$.subscribe((t: Toast | null) => emitted.push(t ? t.message : null));
|
||||
service.show('Auto-dismiss', 'success', 500);
|
||||
tick(500);
|
||||
expect(emitted).toContain(null);
|
||||
}));
|
||||
|
||||
it('calling show() again before timer fires replaces the active toast', fakeAsync(() => {
|
||||
const messages: (string | null)[] = [];
|
||||
service.current$.subscribe((t: Toast | null) => messages.push(t ? t.message : null));
|
||||
service.show('First', 'success', 1000);
|
||||
tick(200);
|
||||
service.show('Second', 'success', 1000);
|
||||
tick(0);
|
||||
const nonNull = messages.filter((m) => m !== null);
|
||||
expect(nonNull[nonNull.length - 1]).toBe('Second');
|
||||
}));
|
||||
});
|
||||
25
ui/src/app/services/toast.service.ts
Normal file
25
ui/src/app/services/toast.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
|
||||
export interface Toast {
|
||||
message: string;
|
||||
type: 'success' | 'error';
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ToastService {
|
||||
private readonly subject = new BehaviorSubject<Toast | null>(null);
|
||||
readonly current$: Observable<Toast | null> = this.subject.asObservable();
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
show(message: string, type: 'success' | 'error' = 'success', duration = 3000): void {
|
||||
if (this.timer !== null) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.subject.next({ message, type });
|
||||
this.timer = setTimeout(() => {
|
||||
this.subject.next(null);
|
||||
this.timer = null;
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
49
ui/src/app/toast/toast.component.spec.ts
Normal file
49
ui/src/app/toast/toast.component.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { ToastComponent } from './toast.component';
|
||||
import { ToastService } from '../services/toast.service';
|
||||
|
||||
describe('ToastComponent', () => {
|
||||
let toastSvc: jasmine.SpyObj<ToastService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
toastSvc = jasmine.createSpyObj('ToastService', [], { current$: of(null) });
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ToastComponent],
|
||||
providers: [{ provide: ToastService, useValue: toastSvc }],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('renders a .toast element with the correct message when current$ emits a toast', async () => {
|
||||
Object.defineProperty(toastSvc, 'current$', { value: of({ message: 'Done', type: 'success' as const }) });
|
||||
const fixture = TestBed.createComponent(ToastComponent);
|
||||
fixture.detectChanges();
|
||||
const el = (fixture.nativeElement as HTMLElement).querySelector('.toast');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el?.textContent?.trim()).toBe('Done');
|
||||
});
|
||||
|
||||
it('adds the success CSS class when type is success', async () => {
|
||||
Object.defineProperty(toastSvc, 'current$', { value: of({ message: 'OK', type: 'success' as const }) });
|
||||
const fixture = TestBed.createComponent(ToastComponent);
|
||||
fixture.detectChanges();
|
||||
const el = (fixture.nativeElement as HTMLElement).querySelector('.toast');
|
||||
expect(el?.classList.contains('success')).toBeTrue();
|
||||
});
|
||||
|
||||
it('adds the error CSS class when type is error', async () => {
|
||||
Object.defineProperty(toastSvc, 'current$', { value: of({ message: 'Fail', type: 'error' as const }) });
|
||||
const fixture = TestBed.createComponent(ToastComponent);
|
||||
fixture.detectChanges();
|
||||
const el = (fixture.nativeElement as HTMLElement).querySelector('.toast');
|
||||
expect(el?.classList.contains('error')).toBeTrue();
|
||||
});
|
||||
|
||||
it('renders nothing when current$ emits null', () => {
|
||||
const fixture = TestBed.createComponent(ToastComponent);
|
||||
fixture.detectChanges();
|
||||
const el = (fixture.nativeElement as HTMLElement).querySelector('.toast');
|
||||
expect(el).toBeNull();
|
||||
});
|
||||
});
|
||||
44
ui/src/app/toast/toast.component.ts
Normal file
44
ui/src/app/toast/toast.component.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ToastService } from '../services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-toast',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div
|
||||
*ngIf="toastService.current$ | async as toast"
|
||||
class="toast"
|
||||
[class.success]="toast.type === 'success'"
|
||||
[class.error]="toast.type === 'error'"
|
||||
>{{ toast.message }}</div>
|
||||
`,
|
||||
styles: [`
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.success {
|
||||
background: var(--surface-raised);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.error {
|
||||
background: var(--danger);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class ToastComponent {
|
||||
constructor(public toastService: ToastService) {}
|
||||
}
|
||||
@@ -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