25 Commits

Author SHA1 Message Date
31bcc1cc82 Fix: Update meta preview and bump manifests to v1.4.3
All checks were successful
Pipeline / UI Lint (push) Successful in 59s
Pipeline / API Unit Tests (push) Successful in 19s
Pipeline / API Lint (push) Successful in 9s
Pipeline / UI Tests (push) Successful in 1m32s
Pipeline / API Integration Tests (push) Successful in 27s
Pipeline / Build & Push API Image (push) Successful in 43s
Pipeline / Build & Push UI Image (push) Successful in 54s
2026-05-15 16:15:02 -04:00
1166e8c5d3 Fix: Update meta preview images after filename refactor
All checks were successful
Pipeline / UI Lint (push) Successful in 58s
Pipeline / API Unit Tests (push) Successful in 19s
Pipeline / API Lint (push) Successful in 9s
Pipeline / UI Tests (push) Successful in 1m32s
Pipeline / API Integration Tests (push) Successful in 27s
Pipeline / Build & Push API Image (push) Successful in 45s
Pipeline / Build & Push UI Image (push) Successful in 52s
2026-05-12 10:41:05 -04:00
8e94c232b4 Chore: Update spec-kit and install memory-loader extension
All checks were successful
Pipeline / UI Lint (push) Successful in 59s
Pipeline / API Unit Tests (push) Successful in 19s
Pipeline / API Lint (push) Successful in 9s
Pipeline / UI Tests (push) Successful in 1m34s
Pipeline / API Integration Tests (push) Successful in 28s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
2026-05-11 20:55:55 +00:00
b00c52baa3 CI: Remove diagnosis step from integration test job
All checks were successful
Pipeline / UI Lint (push) Successful in 57s
Pipeline / API Unit Tests (push) Successful in 20s
Pipeline / API Lint (push) Successful in 9s
Pipeline / UI Tests (push) Successful in 1m33s
Pipeline / API Integration Tests (push) Successful in 27s
Pipeline / Build & Push API Image (push) Successful in 2m56s
Pipeline / Build & Push UI Image (push) Successful in 3m5s
2026-05-10 19:53:14 -04:00
0dc350d534 CI: Update dummy OWNER_PASSWORD in jobs
All checks were successful
Pipeline / API Lint (push) Successful in 9s
Pipeline / API Integration Tests (push) Successful in 28s
Pipeline / UI Lint (push) Successful in 57s
Pipeline / API Unit Tests (push) Successful in 18s
Pipeline / UI Tests (push) Successful in 1m33s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
2026-05-10 19:51:46 -04:00
ac565e4b85 CI: Shrink dummy JWT secret key 2026-05-10 19:49:36 -04:00
0808e027a5 CI: Extend dummy JWT key to pass test without InsecureKeyLengthWarning
Some checks failed
Pipeline / API Lint (push) Successful in 9s
Pipeline / UI Tests (push) Successful in 1m31s
Pipeline / API Integration Tests (push) Failing after 27s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
Pipeline / UI Lint (push) Successful in 57s
Pipeline / API Unit Tests (push) Successful in 19s
2026-05-10 19:45:59 -04:00
fc48b37ee7 CI: Add diagnosis step to integration test job
Some checks failed
Pipeline / UI Lint (push) Successful in 58s
Pipeline / API Lint (push) Successful in 8s
Pipeline / UI Tests (push) Successful in 1m34s
Pipeline / Build & Push UI Image (push) Has been skipped
Pipeline / API Unit Tests (push) Successful in 19s
Pipeline / API Integration Tests (push) Failing after 26s
Pipeline / Build & Push API Image (push) Has been skipped
2026-05-10 19:36:14 -04:00
026467c6db CI: Add explicit username and database to pg_isready healthcheck
Some checks failed
Pipeline / UI Lint (push) Successful in 58s
Pipeline / API Unit Tests (push) Successful in 19s
Pipeline / API Lint (push) Successful in 9s
Pipeline / UI Tests (push) Successful in 1m33s
Pipeline / API Integration Tests (push) Failing after 43s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
2026-05-10 19:33:16 -04:00
e852c773e7 CI: Use legacy Bitnami images for MinIO
Some checks failed
Pipeline / UI Lint (push) Successful in 58s
Pipeline / API Unit Tests (push) Successful in 19s
Pipeline / API Lint (push) Successful in 9s
Pipeline / UI Tests (push) Successful in 1m31s
Pipeline / API Integration Tests (push) Failing after 46s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
2026-05-10 19:27:28 -04:00
69a4d5a084 CI: Try different approach to running PostgreSQL
Some checks failed
Pipeline / UI Lint (push) Successful in 57s
Pipeline / UI Tests (push) Successful in 1m35s
Pipeline / API Unit Tests (push) Successful in 41s
Pipeline / API Lint (push) Successful in 9s
Pipeline / Build & Push API Image (push) Has been cancelled
Pipeline / Build & Push UI Image (push) Has been cancelled
Pipeline / API Integration Tests (push) Has been cancelled
2026-05-10 19:19:14 -04:00
e13a81e31e CI: Run both Postgres and MinIO with --network container:$(hostname)
The Gitea runner executes jobs inside a container. Port-mapped services
bind to the host VM's interface, not to the runner container's loopback,
so localhost:<port> is always unreachable regardless of services: config.

--network container:$(hostname) joins each service to the job container's
network namespace, making both accessible on localhost. Both DB URL and
S3 endpoint use localhost accordingly.

Also adds timeout-minutes: 15 to bound runaway jobs on cancel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:00:46 +00:00
0624795370 CI: Restore Postgres to services, use service name as hostname
Some checks failed
Pipeline / UI Lint (push) Successful in 57s
Pipeline / API Unit Tests (push) Successful in 13s
Pipeline / API Lint (push) Successful in 5s
Pipeline / UI Tests (push) Successful in 1m33s
Pipeline / API Integration Tests (push) Failing after 38s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
Gitea runs jobs in containers, so service containers are networked by
name (same as GitHub Actions with container:). Postgres goes back into
services: and is addressed as 'postgres', not localhost. MinIO stays
as a manual docker run with --network container:$(hostname) since it
needs `server /data` and is addressed as localhost.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:54:05 +00:00
e4a77fdea3 CI: Move Postgres to manual docker run with shared network namespace
Service containers bind ports to the host, not to localhost inside the
job container. Start both Postgres and MinIO manually with
--network container:$(hostname) so they share the job container's
network namespace and are reachable on localhost. Use docker exec for
pg_isready to avoid depending on postgresql-client in the runner image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:52:14 +00:00
22e8717e0c Chore: Exclude alembic/ from Ruff linting
Alembic scaffolds migration files from its own template which uses
pre-3.10 conventions (Union[X, Y], typing.Sequence, etc). Excluding
avoids noise on every new migration without affecting app code coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:50:05 +00:00
8a187b45b9 CI: Fix uv install and MinIO networking
Some checks failed
Pipeline / UI Lint (push) Successful in 56s
Pipeline / UI Tests (push) Successful in 1m28s
Pipeline / API Lint (push) Failing after 4s
Pipeline / API Integration Tests (push) Failing after 32s
Pipeline / API Unit Tests (push) Successful in 3m14s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
Install uv via official installer script instead of pip (pip not
available in the runner environment). Add ~/.local/bin to GITHUB_PATH
so uv is on PATH for subsequent steps.

MinIO: replace -p 9000:9000 (binds to host, unreachable from job
container) with --network container:$(hostname) which joins MinIO to
the job container's network namespace, making localhost:9000 resolve
correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:45:39 +00:00
47e8f80572 CI: Fix API jobs — drop container override, replace bitnami/minio
Some checks failed
Pipeline / UI Lint (push) Successful in 57s
Pipeline / API Unit Tests (push) Failing after 5s
Pipeline / API Lint (push) Failing after 3s
Pipeline / UI Tests (push) Successful in 1m30s
Pipeline / Build & Push API Image (push) Has been cancelled
Pipeline / Build & Push UI Image (push) Has been cancelled
Pipeline / API Integration Tests (push) Has been cancelled
Gitea Actions execs JavaScript actions (actions/checkout) inside the
job container, unlike GitHub Actions which uses the host. The uv Python
image has no Node.js, causing exit 127. Fix: drop container: from all
three API jobs and run on the default ubuntu-latest environment.

Integration tests: Postgres stays as a service container (no special
startup command needed). MinIO moved to a manual docker run step using
quay.io/minio/minio with `server /data` — the only way to pass a
startup command. Bucket created via mc binary downloaded in-step.
Service hostnames change from service-name to localhost now that there
is no explicit job container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:40:00 +00:00
ebfef1b783 Fix: Clean up lint errors introduced in test fixes
Some checks failed
Pipeline / UI Lint (push) Successful in 57s
Pipeline / API Unit Tests (push) Failing after 3s
Pipeline / API Lint (push) Failing after 2s
Pipeline / API Integration Tests (push) Failing after 0s
Pipeline / UI Tests (push) Successful in 1m28s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
- Remove unused NEVER import from detail.component.spec.ts
- Replace `null as unknown as ImageRecord` with `null as unknown as typeof MOCK_IMAGE`
  to match the narrower inferred type (thumbnail_key: null) that setup() expects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:31:50 +00:00
ed98957dfe CI: Update pipeline
Some checks failed
Pipeline / UI Lint (push) Failing after 2m2s
Pipeline / API Unit Tests (push) Failing after 8s
Pipeline / API Lint (push) Failing after 2s
Pipeline / API Integration Tests (push) Failing after 8s
Pipeline / UI Tests (push) Successful in 5m53s
Pipeline / Build & Push API Image (push) Has been skipped
Pipeline / Build & Push UI Image (push) Has been skipped
2026-05-10 18:22:48 -04:00
c0f7954fee CI: Add Gitea Actions pipeline with tests, linting, and release builds
Five test/lint jobs run on every push to master and every PR:
- ui-test: Karma/Firefox in node:22-bullseye
- ui-lint: ESLint via ng lint
- api-unit: pytest tests/unit/ via uv in Python 3.12
- api-lint: Ruff via uvx (no dep install needed)
- api-integration: pytest tests/integration/ with Postgres 16 and bitnami/minio services

Build jobs (build-api, build-ui) run only on v* tags and are gated
behind all five test/lint jobs passing. Images pushed to $REGISTRY.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:13:12 +00:00
c987827f76 Fix: Resolve 13 pre-existing UI test failures across Login, Upload, and Detail components
- LoginComponent: provide ActivatedRoute stub (component reads returnUrl query param)
- UploadComponent: add cdr.markForCheck() to handleUploadError so OnPush view updates
  when the method is called directly; fix success test to check showSuccess not toastMessage
- DetailComponent: drive not-found-card and tag-error tests through component methods
  that call markForCheck() rather than directly mutating state on OnPush components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:01:17 +00:00
6058aa6150 Chore: Bump manifests for v1.4.1 deployment 2026-05-10 14:17:10 -04:00
28113f38e6 Chore: Mark spec 018 as shipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:15:10 +00:00
d883b76c0d Chore: Track active feature pointer for spec 018
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:13:24 +00:00
0ad82e60ac Feat: Replace pagination bar with numbered page buttons and chevron controls
Adds « ‹ [1][2][3][4] › » navigation to the library. Page window
slides to keep the current page in view. Prev/next/first/last controls
are always rendered but disabled at their respective bounds. Also wires
up karmaConfig in angular.json so FirefoxHeadless is used for tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:11:18 +00:00
33 changed files with 1223 additions and 71 deletions

View File

@@ -0,0 +1,234 @@
name: Pipeline
on:
push:
branches: [master]
tags: ['v*']
pull_request:
branches: [master]
jobs:
# ── UI ────────────────────────────────────────────────────────────────────────
ui-test:
name: UI Tests
runs-on: ubuntu-latest
container:
image: node:22-bullseye
steps:
- uses: actions/checkout@v4
- name: Install Firefox
run: apt-get update -qq && apt-get install -y --no-install-recommends firefox-esr
- name: Cache node_modules
uses: actions/cache@v3
with:
path: ui/node_modules
key: npm-${{ hashFiles('ui/package-lock.json') }}
restore-keys: npm-
- name: Install dependencies
run: npm ci
working-directory: ui
- name: Run tests
run: FIREFOX_BIN=/usr/bin/firefox-esr npx ng test --watch=false
working-directory: ui
ui-lint:
name: UI Lint
runs-on: ubuntu-latest
container:
image: node:22-bullseye
steps:
- uses: actions/checkout@v4
- name: Cache node_modules
uses: actions/cache@v3
with:
path: ui/node_modules
key: npm-${{ hashFiles('ui/package-lock.json') }}
restore-keys: npm-
- name: Install dependencies
run: npm ci
working-directory: ui
- name: Run ESLint
run: npm run lint
working-directory: ui
# ── API ───────────────────────────────────────────────────────────────────────
api-unit:
name: API Unit Tests
runs-on: ubuntu-latest
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm
steps:
- name: Install Node (for JS actions)
run: |
apt-get update -qq
apt-get install -y --no-install-recommends nodejs ca-certificates curl
- uses: actions/checkout@v4
- name: Cache uv store
uses: actions/cache@v3
with:
path: /root/.cache/uv
key: uv-${{ hashFiles('api/uv.lock') }}
restore-keys: uv-
- name: Install dependencies
run: uv sync --group dev
working-directory: api
- name: Run unit tests
run: uv run pytest tests/unit/ -q
working-directory: api
env:
DATABASE_URL: postgresql+asyncpg://u:p@localhost/db
S3_ENDPOINT_URL: http://localhost:9000
S3_BUCKET_NAME: test
S3_ACCESS_KEY_ID: key
S3_SECRET_ACCESS_KEY: secret
S3_REGION: us-east-1
API_BASE_URL: http://localhost:8000
JWT_SECRET_KEY: d34db33fc4f3b00bd34db33fc4f3b00b
OWNER_USERNAME: testowner
OWNER_PASSWORD: testpassword
api-lint:
name: API Lint
runs-on: ubuntu-latest
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm
steps:
- name: Install Node (for JS actions)
run: |
apt-get update -qq
apt-get install -y --no-install-recommends nodejs ca-certificates curl
- uses: actions/checkout@v4
- name: Run Ruff
run: uvx ruff check .
working-directory: api
api-integration:
name: API Integration Tests
runs-on: ubuntu-latest
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: reactbin
POSTGRES_PASSWORD: reactbin
POSTGRES_DB: reactbin_test
options: >-
--health-cmd "pg_isready -U reactbin -d reactbin_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
minio:
image: bitnamilegacy/minio:2025.7.23-debian-12-r5
env:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
MINIO_DEFAULT_BUCKETS: reactbin-test
options: >-
--health-cmd "mc ready local || exit 1"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- name: Install Node and curl (for JS actions and mc)
run: |
apt-get update -qq
apt-get install -y --no-install-recommends nodejs ca-certificates curl
- uses: actions/checkout@v4
- name: Cache uv store
uses: actions/cache@v3
with:
path: /root/.cache/uv
key: uv-${{ hashFiles('api/uv.lock') }}
restore-keys: uv-
- name: Install dependencies
run: uv sync --group dev
working-directory: api
- name: Run integration tests
run: uv run pytest tests/integration/ -q
working-directory: api
env:
TEST_DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres/reactbin_test
DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres/reactbin_test
S3_ENDPOINT_URL: http://minio:9000
S3_BUCKET_NAME: reactbin-test
S3_ACCESS_KEY_ID: minioadmin
S3_SECRET_ACCESS_KEY: minioadmin
S3_REGION: us-east-1
API_BASE_URL: http://localhost:8000
JWT_SECRET_KEY: d34db33fc4f3b00bd34db33fc4f3b00b
OWNER_USERNAME: testowner
OWNER_PASSWORD: testpassword
# ── Image builds (tag-only, gated on all jobs) ────────────────────────────────
build-api:
name: Build & Push API Image
runs-on: ubuntu-latest
needs: [ui-test, ui-lint, api-unit, api-lint, api-integration]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./api
file: ./api/Dockerfile.prod
push: true
tags: |
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-api:${{ github.ref_name }}
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-api:latest
build-ui:
name: Build & Push UI Image
runs-on: ubuntu-latest
needs: [ui-test, ui-lint, api-unit, api-lint, api-integration]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./ui
file: ./ui/Dockerfile.prod
push: true
tags: |
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-ui:${{ github.ref_name }}
${{ vars.REGISTRY }}/${{ vars.REPOSITORY }}/reactbin-ui:latest

View File

@@ -18,6 +18,13 @@ hooks:
prompt: Execute speckit.git.feature?
description: Create feature branch before specification
condition: null
- extension: memory-loader
command: speckit.memory-loader.load
enabled: true
optional: false
prompt: Execute speckit.memory-loader.load?
description: Load project memory files before specification
condition: null
before_clarify:
- extension: git
command: speckit.git.commit
@@ -26,6 +33,13 @@ hooks:
prompt: Commit outstanding changes before clarification?
description: Auto-commit before spec clarification
condition: null
- extension: memory-loader
command: speckit.memory-loader.load
enabled: true
optional: false
prompt: Execute speckit.memory-loader.load?
description: Load project memory files before clarification
condition: null
before_plan:
- extension: git
command: speckit.git.commit
@@ -34,6 +48,13 @@ hooks:
prompt: Commit outstanding changes before planning?
description: Auto-commit before implementation planning
condition: null
- extension: memory-loader
command: speckit.memory-loader.load
enabled: true
optional: false
prompt: Execute speckit.memory-loader.load?
description: Load project memory files before planning
condition: null
before_tasks:
- extension: git
command: speckit.git.commit
@@ -42,6 +63,13 @@ hooks:
prompt: Commit outstanding changes before task generation?
description: Auto-commit before task generation
condition: null
- extension: memory-loader
command: speckit.memory-loader.load
enabled: true
optional: false
prompt: Execute speckit.memory-loader.load?
description: Load project memory files before task generation
condition: null
before_implement:
- extension: git
command: speckit.git.commit
@@ -50,6 +78,13 @@ hooks:
prompt: Commit outstanding changes before implementation?
description: Auto-commit before implementation
condition: null
- extension: memory-loader
command: speckit.memory-loader.load
enabled: true
optional: false
prompt: Execute speckit.memory-loader.load?
description: Load project memory files before implementation
condition: null
before_checklist:
- extension: git
command: speckit.git.commit
@@ -58,6 +93,13 @@ hooks:
prompt: Commit outstanding changes before checklist?
description: Auto-commit before checklist generation
condition: null
- extension: memory-loader
command: speckit.memory-loader.load
enabled: true
optional: false
prompt: Execute speckit.memory-loader.load?
description: Load project memory files before checklist generation
condition: null
before_analyze:
- extension: git
command: speckit.git.commit
@@ -66,6 +108,13 @@ hooks:
prompt: Commit outstanding changes before analysis?
description: Auto-commit before analysis
condition: null
- extension: memory-loader
command: speckit.memory-loader.load
enabled: true
optional: false
prompt: Execute speckit.memory-loader.load?
description: Load project memory files before analysis
condition: null
before_taskstoissues:
- extension: git
command: speckit.git.commit

View File

@@ -18,6 +18,20 @@
},
"registered_skills": [],
"installed_at": "2026-05-02T15:15:14.534434+00:00"
},
"memory-loader": {
"version": "1.0.0",
"source": "local",
"manifest_hash": "sha256:d1caef45965accd4316d8aede0a4ac67f910017ea3c501814cfc7e2d8177ab0b",
"enabled": true,
"priority": 10,
"registered_commands": {
"claude": [
"speckit.memory-loader.load"
]
},
"registered_skills": [],
"installed_at": "2026-05-11T20:50:02.702659+00:00"
}
}
}

View File

@@ -0,0 +1,9 @@
# Changelog
## [1.0.0] - 2026-04-20
### Added
- Initial release
- `speckit.memory-loader.load` command to read all `.specify/memory/*.md` files
- `before_*` hooks for specify, plan, tasks, implement, clarify, checklist, and analyze lifecycle commands
- Graceful degradation when memory directory is missing or files are unreadable

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 KevinBrown5280
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,81 @@
# spec-kit-memory-loader
A [Spec Kit](https://github.com/github/spec-kit) extension that loads `.specify/memory/` files before spec-kit lifecycle commands so LLM agents have project governance context (constitution, glossary, conventions, resource standards).
## Problem
Spec-kit lifecycle commands (`/speckit.specify`, `/speckit.plan`, `/speckit.implement`, etc.) execute without awareness of project-specific governance documents stored in `.specify/memory/`. This means:
- Constitution principles are not consulted during specification
- Glossary terms are not available during planning
- Coding conventions are missed during implementation
- Resource standards are ignored during task generation
## Solution
The Memory Loader extension registers `before_*` hooks on all major spec-kit lifecycle commands. Before each command runs, it reads every `.md` file from `.specify/memory/` and outputs their contents, giving the LLM agent full governance context.
## Installation
```bash
# From release
specify extension add memory-loader --from https://github.com/KevinBrown5280/spec-kit-memory-loader/archive/refs/tags/v1.0.0.zip
# From main branch
specify extension add memory-loader --from https://github.com/KevinBrown5280/spec-kit-memory-loader/archive/refs/heads/main.zip
# Development mode (local clone)
specify extension add --dev /path/to/spec-kit-memory-loader
```
## Commands
| Command | Description | Modifies Files? |
|---------|-------------|-----------------|
| `speckit.memory-loader.load` | Read all project memory files and output their contents for context | No — read-only |
## How It Works
1. **Gather**: Reads every `.md` file from `.specify/memory/`
- If the directory does not exist, skips silently
- If a file cannot be read, skips it and continues
2. **Output**: For each file, prints a headed section:
```
## Memory: {filename}
{file contents}
```
3. **Summarize**: After all files, outputs:
```
Context loaded: {memory_count} memory files
```
## Hooks
The extension fires automatically before these lifecycle commands:
| Hook | Command | Description |
|------|---------|-------------|
| `before_specify` | `speckit.memory-loader.load` | Load context before specification |
| `before_plan` | `speckit.memory-loader.load` | Load context before planning |
| `before_tasks` | `speckit.memory-loader.load` | Load context before task generation |
| `before_implement` | `speckit.memory-loader.load` | Load context before implementation |
| `before_clarify` | `speckit.memory-loader.load` | Load context before clarification |
| `before_checklist` | `speckit.memory-loader.load` | Load context before checklist generation |
| `before_analyze` | `speckit.memory-loader.load` | Load context before analysis |
## Design Decisions
- **Read-only** — never modifies any files
- **Graceful degradation** — missing directory or unreadable files are skipped silently
- **Governance only** — loads project-level memory; feature-specific reference docs are handled separately by a companion extension
## Requirements
- Spec Kit >= 0.6.0
## License
MIT

View File

@@ -0,0 +1,33 @@
---
description: "Read all project memory files and output their contents for LLM context"
---
# Load Project Memory
Read ALL `.md` files in `.specify/memory/` and output their contents. This gives you project governance context (constitution, glossary, conventions, resource standards) for the command that follows.
## Steps
1. **Gather**: Read every `.md` file from `.specify/memory/`.
- If the directory does not exist, skip it silently.
- If a file cannot be read, skip it and continue.
2. **Output**: For each file, print a headed section:
```
## Memory: {filename}
{file contents}
```
3. **Summarize**: After all files, output:
```
Context loaded: {memory_count} memory files
```
## Usage Notes
- Designed as a mandatory `before_*` hook that fires before spec-kit lifecycle commands.
- Loads governance context only. Feature-specific reference docs are loaded by the `spec-reference-loader` extension.
- This is a read-only operation — do NOT modify any files.

View File

@@ -0,0 +1,61 @@
schema_version: "1.0"
extension:
id: "memory-loader"
name: "Memory Loader"
version: "1.0.0"
description: "Loads .specify/memory/ files before spec-kit lifecycle commands so LLM agents have project governance context"
author: "KevinBrown5280"
repository: "https://github.com/KevinBrown5280/spec-kit-memory-loader"
license: "MIT"
homepage: "https://github.com/KevinBrown5280/spec-kit-memory-loader"
requires:
speckit_version: ">=0.6.0"
provides:
commands:
- name: "speckit.memory-loader.load"
file: "commands/speckit.memory-loader.load.md"
description: "Read all project memory files and output their contents for context"
hooks:
before_specify:
command: "speckit.memory-loader.load"
optional: false
description: "Load project memory files before specification"
before_plan:
command: "speckit.memory-loader.load"
optional: false
description: "Load project memory files before planning"
before_tasks:
command: "speckit.memory-loader.load"
optional: false
description: "Load project memory files before task generation"
before_implement:
command: "speckit.memory-loader.load"
optional: false
description: "Load project memory files before implementation"
before_clarify:
command: "speckit.memory-loader.load"
optional: false
description: "Load project memory files before clarification"
before_checklist:
command: "speckit.memory-loader.load"
optional: false
description: "Load project memory files before checklist generation"
before_analyze:
command: "speckit.memory-loader.load"
optional: false
description: "Load project memory files before analysis"
tags:
- "memory"
- "context"
- "governance"

View File

@@ -1 +1 @@
{"feature_directory":"specs/017-short-id-migration"}
{"feature_directory":"specs/018-pagination-controls"}

View File

@@ -6,5 +6,5 @@
"here": true,
"integration": "claude",
"script": "sh",
"speckit_version": "0.8.2.dev0"
"speckit_version": "0.8.8"
}

View File

@@ -1,4 +1,15 @@
{
"version": "0.8.8",
"integration_state_schema": 1,
"installed_integrations": [
"claude"
],
"integration_settings": {
"claude": {
"script": "sh",
"invoke_separator": "-"
}
},
"integration": "claude",
"version": "0.8.2.dev0"
"default_integration": "claude"
}

View File

@@ -1,16 +1,16 @@
{
"integration": "claude",
"version": "0.8.2.dev0",
"installed_at": "2026-05-02T15:15:14.461699+00:00",
"version": "0.8.8",
"installed_at": "2026-05-11T20:40:51.902830+00:00",
"files": {
".claude/skills/speckit-analyze/SKILL.md": "2eef0fbff6cad15c9d4714d8986192387811c971a82a1135ab0404f3db0c5e90",
".claude/skills/speckit-checklist/SKILL.md": "26419fc118dcd9c4e1e977460696a04b7757b8fb0a2d1ff9c64732669deb7977",
".claude/skills/speckit-clarify/SKILL.md": "f2560f9f2007b4e995130f0c42633f08837a76a35d94e84091713a6f39bb1064",
".claude/skills/speckit-constitution/SKILL.md": "c1a044aba243ca6aff627fb5e4404feb6f1108d4f7dd174631bee3ae477d6c15",
".claude/skills/speckit-implement/SKILL.md": "da9b4d6f9894d300515c66c057cee74025b27f2238895e3c22b59c6266b5be74",
".claude/skills/speckit-implement/SKILL.md": "6029565c1a56de8919d1846b187cd644f734a0e30a6067a709803e6bc0d2abf7",
".claude/skills/speckit-plan/SKILL.md": "8141ebbce228ad0b422a84e3b995d2bd85de917b96eadd02b5fcb56fb23f2594",
".claude/skills/speckit-specify/SKILL.md": "8599f8e2e3463de7d4f47591565340be2f775fd61b7dd9d2175503bc3b713b77",
".claude/skills/speckit-tasks/SKILL.md": "792589edf0ebf89af797c6bdda4e9d2c9938c696181d6f1484bf7a7cd090efaa",
".claude/skills/speckit-specify/SKILL.md": "caadc05119eca453709a0425ed88d253883f9c55da4c13a4898367653a859483",
".claude/skills/speckit-tasks/SKILL.md": "54c4665be61818ed50aa528bb4c51db3627079b2c67d47f2b01046268288c4a5",
".claude/skills/speckit-taskstoissues/SKILL.md": "99bf5ffd90dcb57b63007c7f659a5160a18ce6feb82889895808e2d277abe83b"
}
}

View File

@@ -1,6 +1,6 @@
{
"integration": "speckit",
"version": "0.8.2.dev0",
"version": "0.8.8",
"installed_at": "2026-05-02T15:15:14.478105+00:00",
"files": {
".specify/scripts/bash/create-new-feature.sh": "bcf4964ca0c6c78717bb42d9e66b8c7e5ee82779cd96afc5aa7b08b75abe5790",
@@ -11,6 +11,7 @@
".specify/templates/plan-template.md": "5ad267630e370c73fe957dafa61bf76d633f3aea9d2f0b5195087d729cdd1e41",
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
".specify/templates/spec-template.md": "785dc50d856dd92d6515eca0761e16dce0c9ba0a3cd07154fd33eae77932422a",
".specify/templates/checklist-template.md": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c"
".specify/templates/checklist-template.md": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c",
".specify/scripts/bash/setup-tasks.sh": "e8d050c63c5afb664a8b671b0b0155513fb9cab0567b335e16b9eb035482aad2"
}
}

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -e
# Parse command line arguments
JSON_MODE=false
for arg in "$@"; do
case "$arg" in
--json) JSON_MODE=true ;;
--help|-h)
echo "Usage: $0 [--json]"
echo " --json Output results in JSON format"
echo " --help Show this help message"
exit 0
;;
*) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;;
esac
done
# Source common functions
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
# Get feature paths
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
eval "$_paths_output"
unset _paths_output
# Validate branch
# If feature.json pins an existing feature directory, branch naming is not required.
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
fi
if [[ ! -f "$IMPL_PLAN" ]]; then
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
echo "Run /speckit.plan first to create the implementation plan." >&2
exit 1
fi
if [[ ! -f "$FEATURE_SPEC" ]]; then
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
echo "Run /speckit.specify first to create the feature structure." >&2
exit 1
fi
# Build available docs list
docs=()
[[ -f "$RESEARCH" ]] && docs+=("research.md")
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
docs+=("contracts/")
fi
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
# Resolve tasks template through override stack
TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true
if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then
echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2
echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2
exit 1
fi
# Output results
if $JSON_MODE; then
if has_jq; then
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
else
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
fi
jq -cn \
--arg feature_dir "$FEATURE_DIR" \
--argjson docs "$json_docs" \
--arg tasks_template "${TASKS_TEMPLATE:-}" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}'
else
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
else
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
json_docs="[${json_docs%,}]"
fi
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")"
fi
else
echo "FEATURE_DIR: $FEATURE_DIR"
echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}"
echo "AVAILABLE_DOCS:"
check_file "$RESEARCH" "research.md"
check_file "$DATA_MODEL" "data-model.md"
check_dir "$CONTRACTS_DIR" "contracts/"
check_file "$QUICKSTART" "quickstart.md"
fi

View File

@@ -8,7 +8,7 @@ description: "Task list template for feature implementation"
**Input**: Design documents from `/specs/[###-feature-name]/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: The examples below include test tasks. Per §5.1 of the constitution, TDD is non-negotiable — test tasks MUST appear before every implementation task. The test task labels below marked "OPTIONAL" refer to the *type* of test (E2E is best-effort per §5.2), not whether tests are written at all.
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
@@ -79,7 +79,7 @@ Examples of foundational tasks (adjust based on your project):
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 1 (REQUIRED per §5.1 — TDD) ⚠️
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
@@ -105,7 +105,7 @@ Examples of foundational tasks (adjust based on your project):
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 2 (REQUIRED per §5.1 — TDD) ⚠️
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
@@ -127,7 +127,7 @@ Examples of foundational tasks (adjust based on your project):
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 3 (REQUIRED per §5.1 — TDD) ⚠️
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
@@ -198,7 +198,7 @@ Examples of foundational tasks (adjust based on your project):
## Parallel Example: User Story 1
```bash
# Launch all tests for User Story 1 together (TDD — write before implementation):
# Launch all tests for User Story 1 together (if tests requested):
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
Task: "Integration test for [user journey] in tests/integration/test_[name].py"

View File

@@ -1,5 +1,4 @@
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan at
`specs/017-short-id-migration/plan.md`.
shell commands, and other important information, read the current plan
<!-- SPECKIT END -->

View File

@@ -30,6 +30,7 @@ dev = [
[tool.ruff]
line-length = 100
target-version = "py312"
exclude = ["alembic/"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]

View File

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

View File

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

View File

View File

@@ -0,0 +1,30 @@
# Specification Quality Checklist: Pagination Controls Redesign
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-10
**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

View File

@@ -0,0 +1,102 @@
# Implementation Plan: Pagination Controls Redesign
**Branch**: `018-pagination-controls` | **Date**: 2026-05-10 | **Spec**: [spec.md](spec.md)
## Summary
Replace the existing "← Previous / Page X of Y / Next →" pagination bar in `LibraryComponent` with six controls: first-page («), previous-page (), up to four numbered page buttons, next-page (), and last-page (»). All logic stays in the existing component — no new component is introduced (§2.6: no speculative abstraction, only one paginated view exists).
## Technical Context
**Language/Version**: TypeScript (strict mode)
**Primary Dependencies**: Angular (latest stable), Karma + Jasmine
**Storage**: N/A — no data layer changes
**Testing**: Angular TestBed unit tests (component spec)
**Target Platform**: Browser SPA
**Project Type**: Web application — UI only
**Performance Goals**: No measurable regression in render or navigation time
**Constraints**: ESLint + Prettier must pass (§7.3); all existing tests must continue to pass (§5.4)
**Scale/Scope**: Single component change; one paginated view in the app
## Constitution Check
| Principle | Status | Notes |
|-----------|--------|-------|
| §2.6 No speculative abstraction | ✅ PASS | Pagination logic stays inline in LibraryComponent; no new component introduced |
| §5.1 Tests alongside implementation | ✅ PASS | Spec tests for window algorithm, disabled states, and navigation covered in tasks |
| §5.2 Test pyramid | ✅ PASS | Unit tests via TestBed; no integration or E2E tests required for a template change |
| §5.4 Suite must pass before done | ✅ PASS | Gate enforced per task |
| §7.3 Lint/format enforced | ✅ PASS | ESLint + Prettier gate on all tasks |
| §8 Scope boundaries | ✅ PASS | No out-of-scope work touched |
No violations. No Complexity Tracking table needed.
## Project Structure
### Documentation (this feature)
```text
specs/018-pagination-controls/
├── plan.md ← this file
├── research.md
└── tasks.md (generated by /speckit-tasks)
```
### Source Code (changed files only)
```text
ui/src/app/library/
├── library.component.ts ← template, styles, class (page window getter + goToPage/firstPage/lastPage methods)
└── library.component.spec.ts ← new tests for window algorithm, disabled states, button navigation
```
No new files. No API changes. No data model changes.
## Page Window Algorithm
Given `currentPage` (1-based) and `totalPages`, compute the array of up to four page numbers to display:
```
start = max(1, currentPage - 1)
end = min(totalPages, start + 3)
start = max(1, end - 3) ← re-anchor if near the end
pages = [start .. end]
```
Examples:
- Page 1 of 20 → [1, 2, 3, 4]
- Page 7 of 20 → [6, 7, 8, 9]
- Page 19 of 20 → [17, 18, 19, 20]
- Page 2 of 3 → [1, 2, 3]
## New Controls Layout
```
« [1] [2] [3] [4] »
```
- `«` disabled when `currentPage === 1`
- `` disabled when `currentPage === 1`
- Active page button has distinct active style
- `` disabled when `currentPage === totalPages`
- `»` disabled when `currentPage === totalPages`
- Entire bar hidden when `totalPages <= 1` (existing behaviour retained)
## Methods to Add/Change
| Method | Change |
|--------|--------|
| `get pageWindow(): number[]` | New getter — returns array of up to 4 page numbers |
| `goToPage(page: number)` | New — navigates to arbitrary page number |
| `firstPage()` | New — navigates to page 1 |
| `lastPage()` | New — navigates to last page |
| `nextPage()` | Existing — no change needed |
| `prevPage()` | Existing — no change needed |
## Research
No unknowns. Tech stack is fixed (Angular/TypeScript). The windowing algorithm is a standard sliding-window with boundary clamping. No external research required.
**Decision**: Keep all logic in `LibraryComponent` (no child component).
**Rationale**: §2.6 prohibits speculative abstraction; only one paginated view exists in the app. Extracting a `PaginationComponent` would be justified only when a second use case appears.
**Alternatives considered**: Standalone `PaginationComponent` — rejected; no second consumer.

View File

@@ -0,0 +1,92 @@
# Feature Specification: Pagination Controls Redesign
**Feature Branch**: `018-pagination-controls`
**Created**: 2026-05-10
**Status**: Draft
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Navigate by Page Number (Priority: P1)
A user browsing the image library wants to jump directly to a specific page by clicking a numbered button rather than stepping through pages one at a time.
**Why this priority**: Direct page navigation is the core value of this feature — without numbered buttons the redesign delivers nothing new.
**Independent Test**: Load the library with enough images to produce multiple pages, confirm four numbered page buttons are visible, click one, and verify the correct page of images loads.
**Acceptance Scenarios**:
1. **Given** the library has more than one page of images, **When** the user views the pagination bar, **Then** up to four page number buttons are visible.
2. **Given** four page buttons are shown, **When** the user clicks a page number button, **Then** the library displays the images for that page and the clicked button appears in an active/selected state.
3. **Given** the total number of pages is four or fewer, **When** the user views the pagination bar, **Then** all pages are shown as numbered buttons with none hidden.
---
### User Story 2 - Step Forward and Backward (Priority: P2)
A user wants to move one page at a time using previous and next controls without having to locate a specific page number.
**Why this priority**: Sequential navigation is a common browsing pattern and complements numbered buttons.
**Independent Test**: With multiple pages available, click the next chevron () and confirm the library advances one page; click the previous chevron () and confirm it retreats one page.
**Acceptance Scenarios**:
1. **Given** the user is not on the last page, **When** they click the next chevron (), **Then** the library advances by one page.
2. **Given** the user is not on the first page, **When** they click the previous chevron (), **Then** the library retreats by one page.
3. **Given** the user is on the first page, **When** they view the pagination bar, **Then** the previous chevron () is visually disabled and non-interactive.
4. **Given** the user is on the last page, **When** they view the pagination bar, **Then** the next chevron () is visually disabled and non-interactive.
---
### User Story 3 - Jump to First and Last Page (Priority: P3)
A user wants to jump directly to the first or last page of the library without stepping through intermediate pages.
**Why this priority**: First/last navigation is a convenience for large libraries; useful but not essential.
**Independent Test**: Navigate to any middle page, click the last-page double chevron (»), and confirm the final page loads; click the first-page double chevron («) and confirm page one loads.
**Acceptance Scenarios**:
1. **Given** the user is not on the first page, **When** they click the first-page double chevron («), **Then** the library jumps to page one.
2. **Given** the user is not on the last page, **When** they click the last-page double chevron (»), **Then** the library jumps to the final page.
3. **Given** the user is on the first page, **When** they view the pagination bar, **Then** the first-page double chevron («) is visually disabled and non-interactive.
4. **Given** the user is on the last page, **When** they view the pagination bar, **Then** the last-page double chevron (») is visually disabled and non-interactive.
---
### Edge Cases
- What happens when there is only one page of images? The entire pagination bar is hidden.
- What happens when the current page is in the middle of a large range (e.g. page 7 of 20)? The four visible page buttons centre around the current page where possible.
- What happens when the current page is near the start or end of the total range? The window of four buttons anchors to the start or end rather than going out of range.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The pagination bar MUST display up to four numbered page buttons at a time.
- **FR-002**: The currently active page button MUST be visually distinguished from inactive page buttons.
- **FR-003**: The pagination bar MUST include a previous-page button () and a next-page button ().
- **FR-004**: The pagination bar MUST include a first-page button («) and a last-page button (»).
- **FR-005**: The previous () and first-page («) controls MUST be disabled when the user is on page one.
- **FR-006**: The next () and last-page (») controls MUST be disabled when the user is on the final page.
- **FR-007**: The visible window of four page buttons MUST shift to keep the current page always in view.
- **FR-008**: The pagination bar MUST be hidden when the total number of pages is one or fewer.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: All six controls («, , up to four page numbers, , ») are visible and correctly labelled on any page with more than four total pages.
- **SC-002**: Disabled controls are visually distinct and cannot be activated by the user.
- **SC-003**: The active page button always reflects the currently displayed page without requiring a page reload.
- **SC-004**: Navigating between pages does not introduce any additional loading delay beyond what the existing image fetch already takes.
## Assumptions
- The existing library already supports offset-based pagination; this feature changes only the navigation controls, not the underlying data fetching.
- The pagination bar is hidden when there is only one page, consistent with common library UX conventions.
- The four-button window shifts so the current page is always visible; no ellipsis or overflow indicator is required.
- Mobile layout is in scope; all controls must remain usable on small screens.

View File

@@ -0,0 +1,125 @@
# Tasks: Pagination Controls Redesign
**Input**: Design documents from `specs/018-pagination-controls/`
**Branch**: `018-pagination-controls`
**Scope**: Two files change — `library.component.ts` (template, styles, class) and `library.component.spec.ts` (tests). No API, no data model, no new files.
---
## Phase 1: Setup
**Purpose**: Baseline verification before touching the component.
- [X] T001 Confirm existing library component tests pass by running `ng test --include=**/library.component.spec.ts --watch=false` in ui/
---
## Phase 2: Foundational
**Purpose**: No blocking infrastructure work required — all three user stories build directly on the existing `LibraryComponent`. Skipped.
---
## Phase 3: User Story 1 — Page Number Navigation (Priority: P1) 🎯 MVP
**Goal**: Replace the "Page X of Y" text with up to four clickable numbered page buttons. User can jump directly to any visible page.
**Independent Test**: With more than four pages of images, four numbered buttons appear; clicking a button loads that page and the button shows as active.
### Tests for User Story 1 (REQUIRED per §5.1)
- [X] T002 [US1] Write failing tests for `pageWindow` getter covering: first page (→ [1,2,3,4]), last page (→ last 4), middle page (current in window), total pages < 4 (all shown) in ui/src/app/library/library.component.spec.ts
### Implementation for User Story 1
- [X] T003 [US1] Add `get pageWindow(): number[]` getter to `LibraryComponent` using the sliding-window algorithm from plan.md in ui/src/app/library/library.component.ts
- [X] T004 [US1] Add `goToPage(page: number)` method to `LibraryComponent` (navigate via router queryParam, call `load()`) in ui/src/app/library/library.component.ts
- [X] T005 [US1] Replace the `<span class="page-indicator">Page {{ currentPage }} of {{ totalPages }}</span>` with `*ngFor` numbered page buttons; add `.page-btn` and `.page-btn.active` styles; verify T002 tests pass in ui/src/app/library/library.component.ts
**Checkpoint**: Four numbered page buttons visible; clicking one loads the correct page; active button is highlighted.
---
## Phase 4: User Story 2 — Previous/Next Chevrons (Priority: P2)
**Goal**: Replace text "← Previous" / "Next →" buttons with chevrons that are always rendered but visually disabled and non-interactive when at the first or last page.
**Independent Test**: On page 1 is disabled; on last page is disabled; clicking either on a valid page advances or retreats by one.
### Tests for User Story 2 (REQUIRED per §5.1)
- [X] T006 [US2] Write failing tests for disabled attribute and non-interactivity: disabled on page 1, disabled on last page, enabled otherwise in ui/src/app/library/library.component.spec.ts
### Implementation for User Story 2
- [X] T007 [US2] Replace the `*ngIf`-gated ← Previous / Next → buttons with always-rendered `<button [disabled]="currentPage === 1"></button>` and `<button [disabled]="currentPage === totalPages"></button>`; add `.pag-btn:disabled` style; verify T006 tests pass in ui/src/app/library/library.component.ts
**Checkpoint**: and always visible; disabled at bounds; single-page step works.
---
## Phase 5: User Story 3 — First/Last Jump Buttons (Priority: P3)
**Goal**: Add « and » buttons that jump directly to page 1 and the last page, disabled when already there.
**Independent Test**: From any middle page, « jumps to page 1 and » jumps to the last page; both are disabled when already at the respective bound.
### Tests for User Story 3 (REQUIRED per §5.1)
- [X] T008 [US3] Write failing tests for `firstPage()` and `lastPage()` methods and disabled states of « » at page boundaries in ui/src/app/library/library.component.spec.ts
### Implementation for User Story 3
- [X] T009 [US3] Add `firstPage()` and `lastPage()` methods to `LibraryComponent`; add `<button [disabled]="currentPage === 1">«</button>` and `<button [disabled]="currentPage === totalPages">»</button>` to each end of the pagination bar; verify T008 tests pass in ui/src/app/library/library.component.ts
**Checkpoint**: Full bar renders as [1][2][3][4] »`; all disabled states correct.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T010 Apply final styles: consistent button sizing, gap spacing, and mobile-friendly layout (flex-wrap or min-width as needed) for the full pagination bar in ui/src/app/library/library.component.ts
- [X] T011 Run ESLint and Prettier on ui/src/app/library/ and resolve any issues
---
## Dependencies & Execution Order
- **T001**: Run first — baseline gate
- **T002 → T003 → T004 → T005**: Sequential (tests before implementation; each method before its template usage)
- **T006 → T007**: Sequential (tests before implementation)
- **T008 → T009**: Sequential (tests before implementation)
- **T010, T011**: After all story phases complete; can run in either order
### User Story Dependencies
- **US1 (P1)**: Independent — starts after T001
- **US2 (P2)**: Starts after US1 is complete (shares same template section)
- **US3 (P3)**: Starts after US2 is complete (adds to the same template section)
All three stories touch the same two files, so parallel execution is not applicable here.
---
## Implementation Strategy
### MVP (User Story 1 only)
1. T001: Baseline check
2. T002T005: Numbered buttons + goToPage
3. **Validate**: Four page buttons work, active state correct
4. Defer US2 and US3 if shipping early
### Full Delivery
1. T001 baseline → US1 (T002T005) → US2 (T006T007) → US3 (T008T009) → Polish (T010T011)
2. Each story checkpoint validates independence before moving on
---
## Notes
- `pageWindow` algorithm: `start = max(1, currentPage-1); end = min(totalPages, start+3); start = max(1, end-3); pages = [start..end]`
- No `[P]` markers — all tasks share the same two files and must run sequentially
- Entire pagination bar hidden when `totalPages <= 1` (existing behaviour; do not regress)

View File

@@ -59,6 +59,7 @@
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"karmaConfig": "karma.conf.js",
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "tsconfig.spec.json",
"assets": [

View File

@@ -14,7 +14,13 @@ module.exports = function (config) {
jasmineHtmlReporter: { suppressAll: true },
coverageReporter: { dir: require('path').join(__dirname, './coverage/reactbin-ui'), subdir: '.', reporters: [{ type: 'html' }, { type: 'text-summary' }] },
reporters: ['progress', 'kjhtml'],
browsers: ['Chrome'],
customLaunchers: {
FirefoxHeadless: {
base: 'Firefox',
flags: ['--headless'],
},
},
browsers: ['FirefoxHeadless'],
restartOnFileChange: true,
});
};

View File

@@ -113,17 +113,15 @@ describe('DetailComponent', () => {
});
it('not-found card shown when image is null, loading is false, error is false', () => {
const { fixture, component } = setup('img-1', of(MOCK_IMAGE));
component.image = null;
component.loading = false;
component.error = false;
fixture.detectChanges();
// Service returns null → fetchImage sets image=null, loading=false, markForCheck()
const { fixture } = setup('img-1', of(null as unknown as typeof MOCK_IMAGE));
expect((fixture.nativeElement as HTMLElement).querySelector('.not-found-card')).not.toBeNull();
});
it('tag error element uses danger styling class', () => {
const { fixture, component } = setup();
component.tagError = 'Invalid tag: special characters not allowed';
const { fixture, component, imgSvc } = setup();
spyOn(imgSvc, 'updateTags').and.returnValue(throwError(() => ({ error: { detail: 'Invalid tag' } })));
component.addTag('bad#tag');
fixture.detectChanges();
const errEl = (fixture.nativeElement as HTMLElement).querySelector('.tag-error');
expect(errEl).not.toBeNull();

View File

@@ -155,15 +155,72 @@ describe('LibraryComponent', () => {
expect(link).not.toBeNull();
});
// ---- Pagination: US1 ----
// ---- Pagination: page window (T002) ----
it('page indicator shows "Page 1 of 2" when totalPages > 1', () => {
it('pageWindow returns [1,2,3,4] on page 1 of 20', () => {
const fixture = TestBed.createComponent(LibraryComponent);
fixture.componentInstance.currentPage = 1;
fixture.componentInstance.totalPages = 20;
expect(fixture.componentInstance.pageWindow).toEqual([1, 2, 3, 4]);
});
it('pageWindow returns [17,18,19,20] on page 20 of 20', () => {
const fixture = TestBed.createComponent(LibraryComponent);
fixture.componentInstance.currentPage = 20;
fixture.componentInstance.totalPages = 20;
expect(fixture.componentInstance.pageWindow).toEqual([17, 18, 19, 20]);
});
it('pageWindow returns [6,7,8,9] on page 7 of 20', () => {
const fixture = TestBed.createComponent(LibraryComponent);
fixture.componentInstance.currentPage = 7;
fixture.componentInstance.totalPages = 20;
expect(fixture.componentInstance.pageWindow).toEqual([6, 7, 8, 9]);
});
it('pageWindow returns all pages when totalPages < 4', () => {
const fixture = TestBed.createComponent(LibraryComponent);
fixture.componentInstance.currentPage = 2;
fixture.componentInstance.totalPages = 3;
expect(fixture.componentInstance.pageWindow).toEqual([1, 2, 3]);
});
it('pageWindow returns [1] when totalPages is 1', () => {
const fixture = TestBed.createComponent(LibraryComponent);
fixture.componentInstance.currentPage = 1;
fixture.componentInstance.totalPages = 1;
expect(fixture.componentInstance.pageWindow).toEqual([1]);
});
it('numbered page buttons are rendered (T002)', () => {
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');
const pageBtns = (fixture.nativeElement as HTMLElement).querySelectorAll('.page-btn');
expect(pageBtns.length).toBe(2); // 2 total pages
expect(pageBtns[0].textContent?.trim()).toBe('1');
expect(pageBtns[1].textContent?.trim()).toBe('2');
});
it('active page button has .active class', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
const activeBtn = (fixture.nativeElement as HTMLElement).querySelector('.page-btn.active');
expect(activeBtn).not.toBeNull();
expect(activeBtn?.textContent?.trim()).toBe('1');
});
it('goToPage() calls imageService.list with correct offset', () => {
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.goToPage(2);
expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 24);
});
it('total count renders with correct number', () => {
@@ -175,32 +232,6 @@ describe('LibraryComponent', () => {
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);
@@ -242,7 +273,58 @@ describe('LibraryComponent', () => {
expect(listSpy).toHaveBeenCalledWith(['cat'], jasmine.any(Number), 0);
});
// ---- Pagination: US2 — URL state ----
// ---- Pagination: disabled states (T006) ----
it('prev-btn () is disabled on page 1', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
const prevBtn = (fixture.nativeElement as HTMLElement).querySelector('.prev-btn') as HTMLButtonElement;
expect(prevBtn).not.toBeNull();
expect(prevBtn.disabled).toBeTrue();
});
it('next-btn () is disabled 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();
const nextBtn = (fixture.nativeElement as HTMLElement).querySelector('.next-btn') as HTMLButtonElement;
expect(nextBtn).not.toBeNull();
expect(nextBtn.disabled).toBeTrue();
});
it('prev-btn () is enabled when not on page 1', () => {
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();
const prevBtn = (fixture.nativeElement as HTMLElement).querySelector('.prev-btn') as HTMLButtonElement;
expect(prevBtn.disabled).toBeFalse();
});
it('next-btn () is enabled 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();
const nextBtn = (fixture.nativeElement as HTMLElement).querySelector('.next-btn') as HTMLButtonElement;
expect(nextBtn.disabled).toBeFalse();
});
it('both prev and next buttons always rendered when totalPages > 1', () => {
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')).not.toBeNull();
});
// ---- Pagination: URL state ----
it('reads ?page=2 from queryParamMap on init and calls list with offset=24', () => {
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
@@ -260,7 +342,6 @@ describe('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);
});
@@ -304,4 +385,69 @@ describe('LibraryComponent', () => {
card.click();
expect(router.navigate).toHaveBeenCalledWith(['/i', 'ShrtImg1']);
});
// ---- Pagination: « » first/last (T008) ----
it('firstPage() navigates to page 1', () => {
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.firstPage();
expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 0);
expect(fixture.componentInstance.currentPage).toBe(1);
});
it('lastPage() navigates to last page', () => {
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.lastPage();
expect(fixture.componentInstance.currentPage).toBe(fixture.componentInstance.totalPages);
});
it('first-page button («) is disabled on page 1', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
const firstBtn = (fixture.nativeElement as HTMLElement).querySelector('.first-btn') as HTMLButtonElement;
expect(firstBtn).not.toBeNull();
expect(firstBtn.disabled).toBeTrue();
});
it('last-page button (») is disabled 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();
const lastBtn = (fixture.nativeElement as HTMLElement).querySelector('.last-btn') as HTMLButtonElement;
expect(lastBtn).not.toBeNull();
expect(lastBtn.disabled).toBeTrue();
});
it('first-page button («) is enabled when not on page 1', () => {
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();
const firstBtn = (fixture.nativeElement as HTMLElement).querySelector('.first-btn') as HTMLButtonElement;
expect(firstBtn.disabled).toBeFalse();
});
it('last-page button (») is enabled 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();
const lastBtn = (fixture.nativeElement as HTMLElement).querySelector('.last-btn') as HTMLButtonElement;
expect(lastBtn.disabled).toBeFalse();
});
});

View File

@@ -90,9 +90,17 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
<!-- 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>
<button class="pag-btn first-btn" [disabled]="currentPage === 1" (click)="firstPage()" aria-label="First page">«</button>
<button class="pag-btn prev-btn" [disabled]="currentPage === 1" (click)="prevPage()" aria-label="Previous page"></button>
<button
*ngFor="let p of pageWindow"
class="pag-btn page-btn"
[class.active]="p === currentPage"
(click)="goToPage(p)"
[attr.aria-current]="p === currentPage ? 'page' : null"
>{{ p }}</button>
<button class="pag-btn next-btn" [disabled]="currentPage === totalPages" (click)="nextPage()" aria-label="Next page"></button>
<button class="pag-btn last-btn" [disabled]="currentPage === totalPages" (click)="lastPage()" aria-label="Last page">»</button>
</div>
</div>
`,
@@ -130,10 +138,11 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
.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); }
.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; }
.pagination-bar { display: flex; justify-content: center; align-items: center; gap: 6px; margin: 16px 0 24px; flex-wrap: wrap; }
.pag-btn { min-width: 36px; height: 36px; padding: 0 10px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; font-size: 0.95rem; transition: border-color var(--transition), background var(--transition); }
.pag-btn:hover:not(:disabled) { border-color: var(--border-focus); }
.pag-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.page-btn.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
`],
})
export class LibraryComponent implements OnInit {
@@ -225,6 +234,37 @@ export class LibraryComponent implements OnInit {
});
}
get pageWindow(): number[] {
let start = Math.max(1, this.currentPage - 1);
const end = Math.min(this.totalPages, start + 3);
start = Math.max(1, end - 3);
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}
goToPage(page: number): void {
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
this.currentPage = page;
this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' });
this.load();
}
}
firstPage(): void {
if (this.currentPage !== 1) {
this.currentPage = 1;
this.router.navigate([], { queryParams: { page: 1 }, queryParamsHandling: 'merge' });
this.load();
}
}
lastPage(): void {
if (this.currentPage !== this.totalPages) {
this.currentPage = this.totalPages;
this.router.navigate([], { queryParams: { page: this.totalPages }, queryParamsHandling: 'merge' });
this.load();
}
}
nextPage(): void {
if (this.currentPage < this.totalPages) {
this.currentPage++;

View File

@@ -1,6 +1,6 @@
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { LoginComponent } from './login.component';
@@ -20,6 +20,7 @@ describe('LoginComponent', () => {
providers: [
{ provide: AuthService, useValue: authService },
{ provide: Router, useValue: router },
{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } },
],
}).compileComponents();

View File

@@ -48,7 +48,7 @@ describe('UploadComponent', () => {
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
await component.handleUploadResponse({ id: 'xyz', short_id: 'XyZ12345', duplicate: false } as Parameters<typeof component.handleUploadResponse>[0]);
expect(component.toastMessage).toBeTruthy();
expect(component.showSuccess).toBeTrue();
expect(router.navigate).toHaveBeenCalledWith(['/i', 'XyZ12345']);
});

View File

@@ -192,6 +192,7 @@ export class UploadComponent {
} else {
this.errorMessage = 'Upload failed. Please try again.';
}
this.cdr.markForCheck();
}
resetForm(): void {

View File

@@ -10,12 +10,12 @@
<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">
<meta property="og:image" content="https://cdn.reactbin.juggalol.com/baYB6eiC">
<!-- 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">
<meta name="twitter:image" content="https://cdn.reactbin.juggalol.com/baYB6eiC">
<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">