38 Commits

Author SHA1 Message Date
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
40ceecda76 Chore: Mark all shipped specs with SHIPPED marker file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:37:28 +00:00
fca3190eb1 Chore: Add comment to Dockerfile.prod flagging explicit directory list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:42:16 +00:00
c210978261 Chore: Revert initContainer command after successful migration 2026-05-09 20:39:22 -04:00
a61c67614f Chore: Bump manifests and add migration init container sequence 2026-05-09 20:26:51 -04:00
27425889b3 Fix: Include scripts/ in production Docker image
Dockerfile.prod explicitly listed copied directories and omitted
scripts/, so the migration script was absent from the prod image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:18:48 +00:00
61d923d5be Feat: Replace UUID image identifiers with 8-character base62 short IDs
Short IDs become the canonical identifier in URLs (/i/:short_id),
MinIO/R2 storage keys, and all API responses. Hash-based deduplication
is preserved. Includes two-phase Alembic migration (003 adds nullable
column, 004 enforces NOT NULL) with a backfill script to copy storage
objects and populate short_id for existing images.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:13:55 +00:00
87eb2703f5 Chore: Bump manifests for v1.3.1 2026-05-09 18:43:33 -04:00
bc0f5173c0 Feat: Substring tag search — match anywhere in tag name
Changes prefix-only LIKE to case-insensitive ILIKE with leading
wildcard so queries like "at" now match "cat", "scatter", etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:42:23 +00:00
309cfce71c Chore: Bump manifests for v1.3.0 release 2026-05-09 18:34:26 -04:00
b094389131 Fix: Await second microtask tick in copyUrl reject test
The .catch() handler on a rejected promise resolves on the second
microtask tick, not the first — one extra await Promise.resolve() is
needed before the assertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:31:58 +00:00
7d49c12ce2 Feat: Add Copy URL button and reusable toast notification system
Detail page now has a "Copy URL" button that copies the image's direct
file URL to the clipboard. A toast service (BehaviorSubject-backed,
auto-dismissing after 3s) confirms success or failure. ToastComponent
is registered at the app root and available to all future features.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:21:48 +00:00
443887ea93 Chore: Bump manifests for v1.2.1 2026-05-09 17:31:28 -04:00
e4bfe13072 Feat: Add gradient fade on truncated tag rows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:30:18 +00:00
0a76bb03b5 Fix: Prevent partial second tag row on image cards
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:27:39 +00:00
8cbf1e527a Fix: React to external URL changes and cap tag-row height in library
Clicking the Reactbin home link (or any navigation to / that removes
?page=) now resets the displayed page by subscribing to queryParamMap
for post-init URL changes. Cards with many tags no longer push the
pagination bar down since the tag row is clamped to one line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:24:44 +00:00
a280d8c761 Chore: Bump manifests for v1.2.0 release 2026-05-09 17:10:03 -04:00
781be909bc Feat: Replace Load More with Previous/Next pagination in library
Page size changes from 50 to 24. Library now shows discrete page navigation
with a "Page N of M" indicator, total image count, and URL state (?page=N)
so pages are bookmarkable and the browser Back button works. Tag filter
resets to page 1. Out-of-range page params are clamped silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:08:42 +00:00
e5e1acb533 Chore: Bump manifests after adding previews 2026-05-09 16:18:50 -04:00
c9bfdaf241 Feat: Add Open Graph and Twitter Card meta tags
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 20:17:35 +00:00
94 changed files with 3615 additions and 177 deletions

View File

@@ -0,0 +1,241 @@
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: d34db33fc4f3b00bd34db33fc4f3b00bd34db33fc4f3b00bd34db33fc4f3b00b
OWNER_USERNAME: testowner
OWNER_PASSWORD: testpass
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: Diagnose
run: |
echo "=== resolv.conf ==="
cat /etc/resolv.conf
echo "=== Service DNS ==="
getent hosts postgres || echo "postgres: not in DNS"
getent hosts minio || echo "minio: not in DNS"
- 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: d34db33fc4f3b00bd34db33fc4f3b00bd34db33fc4f3b00bd34db33fc4f3b00b
OWNER_USERNAME: testowner
OWNER_PASSWORD: testpass
# ── 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

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

View File

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

View File

@@ -34,9 +34,11 @@ RUN groupadd --system --gid 1001 appgroup \
&& useradd --system --uid 1001 --gid 1001 --no-create-home appuser
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
# Explicitly list every source directory — add new top-level dirs here or they won't exist in prod
COPY --chown=appuser:appgroup app/ ./app/
COPY --chown=appuser:appgroup alembic/ ./alembic/
COPY --chown=appuser:appgroup alembic.ini .
COPY --chown=appuser:appgroup scripts/ ./scripts/
USER appuser

View File

@@ -0,0 +1,24 @@
"""add short_id column to images
Revision ID: 003
Revises: 002
Create Date: 2026-05-09
"""
from alembic import op
import sqlalchemy as sa
revision = "003"
down_revision = "002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("images", sa.Column("short_id", sa.String(8), nullable=True))
op.create_index("ix_images_short_id", "images", ["short_id"], unique=True)
def downgrade() -> None:
op.drop_index("ix_images_short_id", table_name="images")
op.drop_column("images", "short_id")

View File

@@ -0,0 +1,24 @@
"""set short_id NOT NULL on images
Revision ID: 004
Revises: 003
Create Date: 2026-05-09
IMPORTANT: Run migrate_to_short_ids.py script BEFORE applying this migration.
This migration will fail if any rows still have short_id IS NULL.
"""
from alembic import op
revision = "004"
down_revision = "003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column("images", "short_id", nullable=False)
def downgrade() -> None:
op.alter_column("images", "short_id", nullable=True)

View File

@@ -92,9 +92,7 @@ class LoginRateLimiter:
rec.failures += 1
if rec.failures >= self._max:
rec.blocked_until = now + self._cooldown
logger.warning(
"Login blocked for %s after %d failures", ip, rec.failures
)
logger.warning("Login blocked for %s after %d failures", ip, rec.failures)
def record_success(self, ip: str) -> None:
with self._lock:

View File

@@ -22,6 +22,7 @@ class Image(Base):
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
width: Mapped[int] = mapped_column(Integer, nullable=False)
height: Mapped[int] = mapped_column(Integer, nullable=False)
short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True)
storage_key: Mapped[str] = mapped_column(String(64), nullable=False)
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(

View File

@@ -27,6 +27,14 @@ class ImageRepository:
)
return result.scalar_one_or_none()
async def get_by_short_id(self, short_id: str) -> Image | None:
result = await self._session.execute(
select(Image)
.where(Image.short_id == short_id)
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
)
return result.scalar_one_or_none()
async def create(
self,
*,
@@ -37,6 +45,7 @@ class ImageRepository:
width: int,
height: int,
storage_key: str,
short_id: str,
thumbnail_key: str | None = None,
) -> Image:
image = Image(
@@ -47,6 +56,7 @@ class ImageRepository:
width=width,
height=height,
storage_key=storage_key,
short_id=short_id,
thumbnail_key=thumbnail_key,
)
self._session.add(image)

View File

@@ -48,9 +48,7 @@ class TagRepository:
for name in tag_names:
tag = await self.upsert_by_name(name)
existing = await self._session.execute(
select(ImageTag).where(
ImageTag.image_id == image.id, ImageTag.tag_id == tag.id
)
select(ImageTag).where(ImageTag.image_id == image.id, ImageTag.tag_id == tag.id)
)
if existing.scalar_one_or_none() is None:
self._session.add(ImageTag(image_id=image.id, tag_id=tag.id))
@@ -88,7 +86,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)
@@ -102,7 +100,6 @@ class TagRepository:
rows = await self._session.execute(paginated)
items = [
{"id": str(tag.id), "name": tag.name, "image_count": count}
for tag, count in rows.all()
{"id": str(tag.id), "name": tag.name, "image_count": count} for tag, count in rows.all()
]
return items, total

View File

@@ -1,7 +1,7 @@
import asyncio
import logging
import re
import struct
import uuid
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
@@ -15,7 +15,7 @@ from app.repositories.image_repo import ImageRepository
from app.repositories.tag_repo import TagRepository
from app.storage.backend import StorageBackend
from app.thumbnail import generate_thumbnail
from app.utils import compute_sha256
from app.utils import compute_sha256, generate_short_id
from app.validation import FileSizeError, MimeTypeError, validate_file_size, validate_mime_type
logger = logging.getLogger(__name__)
@@ -23,22 +23,35 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["images"])
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
def _error(detail: str, code: str, status: int):
raise HTTPException(status_code=status, detail={"detail": detail, "code": code})
def _validate_short_id(short_id: str) -> str:
if not _SHORT_ID_RE.match(short_id):
raise HTTPException(
status_code=422,
detail={"detail": "Invalid image ID", "code": "invalid_short_id"},
)
return short_id
def _image_to_dict(
image: Image, *, cdn_base: str | None = None, duplicate: bool | None = None
) -> dict[str, Any]:
_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"
file_url = f"{_base}/{image.storage_key}" if _base else f"/api/v1/i/{image.short_id}/file"
thumbnail_url = (
(f"{_base}/{image.thumbnail_key}" if _base else f"/api/v1/images/{image.id}/thumbnail")
(f"{_base}/{image.thumbnail_key}" if _base else f"/api/v1/i/{image.short_id}/thumbnail")
if image.thumbnail_key
else None
)
data: dict[str, Any] = {
"id": str(image.id),
"short_id": image.short_id,
"hash": image.hash,
"filename": image.filename,
"mime_type": image.mime_type,
@@ -169,29 +182,49 @@ async def upload_image(
)
width, height = _read_image_dimensions(data, mime_type)
await storage.put(hash_hex, data, mime_type)
thumbnail_key: str | None = None
try:
thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)
await storage.put(f"{hash_hex}-thumb", thumb_bytes, "image/webp")
thumbnail_key = f"{hash_hex}-thumb"
except Exception:
logger.warning(
"Thumbnail generation failed for %s; upload will proceed without thumbnail", hash_hex
from sqlalchemy.exc import IntegrityError
for _ in range(10):
short_id = generate_short_id()
await storage.put(short_id, data, mime_type)
thumbnail_key: str | None = None
try:
thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)
await storage.put(f"{short_id}-thumb", thumb_bytes, "image/webp")
thumbnail_key = f"{short_id}-thumb"
except Exception:
logger.warning(
"Thumbnail generation failed for %s; proceeding without thumbnail", short_id
)
try:
image = await image_repo.create(
hash_hex=hash_hex,
filename=file.filename or "upload",
mime_type=mime_type,
size_bytes=len(data),
width=width,
height=height,
storage_key=short_id,
short_id=short_id,
thumbnail_key=thumbnail_key,
)
break
except IntegrityError:
await db.rollback()
await storage.delete(short_id)
if thumbnail_key:
await storage.delete(thumbnail_key)
thumbnail_key = None
continue
else:
raise HTTPException(
status_code=500,
detail={"detail": "Failed to assign unique ID", "code": "id_collision"},
)
image = await image_repo.create(
hash_hex=hash_hex,
filename=file.filename or "upload",
mime_type=mime_type,
size_bytes=len(data),
width=width,
height=height,
storage_key=hash_hex,
thumbnail_key=thumbnail_key,
)
if tag_names:
tag_repo = TagRepository(db)
await tag_repo.attach_tags(image, tag_names)
@@ -221,15 +254,16 @@ async def list_images(
}
@router.get("/images/{image_id}")
@router.get("/i/{short_id}")
async def get_image(
image_id: uuid.UUID,
short_id: str,
db: AsyncSession = Depends(get_db),
settings=Depends(get_settings),
):
_validate_short_id(short_id)
_cdn_base = settings.s3_public_base_url
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,
@@ -238,14 +272,15 @@ async def get_image(
return _image_to_dict(image, cdn_base=_cdn_base)
@router.get("/images/{image_id}/file")
@router.get("/i/{short_id}/file")
async def serve_image_file(
image_id: uuid.UUID,
short_id: str,
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
):
_validate_short_id(short_id)
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,
@@ -268,14 +303,15 @@ async def serve_image_file(
)
@router.get("/images/{image_id}/thumbnail")
@router.get("/i/{short_id}/thumbnail")
async def serve_image_thumbnail(
image_id: uuid.UUID,
short_id: str,
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
):
_validate_short_id(short_id)
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,
@@ -300,17 +336,18 @@ async def serve_image_thumbnail(
)
@router.patch("/images/{image_id}/tags")
@router.patch("/i/{short_id}/tags")
async def update_image_tags(
image_id: uuid.UUID,
short_id: str,
body: dict,
db: AsyncSession = Depends(get_db),
_: Identity = Depends(require_auth),
settings=Depends(get_settings),
):
_validate_short_id(short_id)
_cdn_base = settings.s3_public_base_url
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,
@@ -332,15 +369,16 @@ async def update_image_tags(
return _image_to_dict(image, cdn_base=_cdn_base)
@router.delete("/images/{image_id}", status_code=204)
@router.delete("/i/{short_id}", status_code=204)
async def delete_image(
image_id: uuid.UUID,
short_id: str,
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
_: Identity = Depends(require_auth),
):
_validate_short_id(short_id)
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,

View File

@@ -1,5 +1,13 @@
import hashlib
import secrets
import string
BASE62 = string.ascii_letters + string.digits
def compute_sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def generate_short_id(length: int = 8) -> str:
return "".join(secrets.choice(BASE62) for _ in range(length))

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

0
api/scripts/__init__.py Normal file
View File

View File

@@ -0,0 +1,107 @@
"""
Migrate existing images to use short_id-based storage keys.
Run after applying Alembic migration 003 (adds short_id column).
Run before applying migration 004 (sets short_id NOT NULL).
Usage:
python -m scripts.migrate_to_short_ids
"""
import asyncio
import logging
from typing import Any
from sqlalchemy import select
from app.database import get_session_factory
from app.models import Image
from app.storage.s3_backend import S3StorageBackend
from app.utils import generate_short_id
logger = logging.getLogger(__name__)
async def migrate_image(image: Any, storage: Any, session: Any) -> bool:
"""Migrate one image to a short_id-based key. Returns True if migrated, False if skipped."""
if image.short_id is not None:
return False
new_short_id = generate_short_id()
old_key = image.storage_key
old_thumb_key = image.thumbnail_key
try:
data = await storage.get(old_key)
await storage.put(new_short_id, data, image.mime_type)
# Verify copy succeeded
await storage.get(new_short_id)
except Exception as exc:
logger.error("Failed to copy storage object for image %s: %s", image.id, exc)
return False
new_thumb_key: str | None = None
if old_thumb_key:
try:
thumb_data = await storage.get(old_thumb_key)
new_thumb_key = f"{new_short_id}-thumb"
await storage.put(new_thumb_key, thumb_data, "image/webp")
await storage.get(new_thumb_key)
except Exception as exc:
logger.warning("Failed to copy thumbnail for image %s: %s", image.id, exc)
new_thumb_key = None
try:
image.short_id = new_short_id
image.storage_key = new_short_id
image.thumbnail_key = new_thumb_key
await session.flush()
await storage.delete(old_key)
if old_thumb_key and new_thumb_key:
await storage.delete(old_thumb_key)
except Exception as exc:
logger.error("Failed to update DB record for image %s: %s", image.id, exc)
return False
return True
async def run_migration(images: list, storage: Any, session: Any) -> tuple[int, int, int]:
"""Process a list of images. Returns (migrated, skipped, failed) counts."""
migrated = skipped = failed = 0
for image in images:
if image.short_id is not None:
skipped += 1
continue
try:
success = await migrate_image(image, storage, session)
if success:
migrated += 1
else:
failed += 1
except Exception as exc:
logger.error("Unexpected error migrating image %s: %s", image.id, exc)
failed += 1
return migrated, skipped, failed
async def main() -> None:
logging.basicConfig(level=logging.INFO)
storage = S3StorageBackend()
async with get_session_factory()() as session:
result = await session.execute(select(Image).where(Image.short_id.is_(None)))
images = list(result.scalars().all())
logger.info("Found %d images to migrate", len(images))
migrated, skipped, failed = await run_migration(images, storage, session)
await session.commit()
print(f"Migrated: {migrated}, Skipped: {skipped}, Failed: {failed}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,10 +1,9 @@
"""
T065 — DELETE /api/v1/images/{id} → 204; subsequent GET returns 404
T065 — DELETE /api/v1/i/{short_id} → 204; subsequent GET returns 404
T066 — DELETE verifies MinIO object is removed
T067 — DELETE of unknown ID → 404 image_not_found
"""
import io
import uuid
import pytest
from PIL import Image as PILImage
@@ -28,12 +27,12 @@ async def test_delete_removes_record(authed_client):
files={"file": ("del-test.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
assert delete_resp.status_code == 204
get_resp = await client.get(f"/api/v1/images/{image_id}")
get_resp = await client.get(f"/api/v1/i/{image_id}")
assert get_resp.status_code == 404
assert get_resp.json()["code"] == "image_not_found"
@@ -49,13 +48,13 @@ async def test_delete_removes_storage_object(authed_client):
headers=headers,
)
assert upload.status_code in (200, 201)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
assert delete_resp.status_code == 204
# Confirm storage redirect no longer works (404 since record is gone)
file_resp = await client.get(f"/api/v1/images/{image_id}/file")
file_resp = await client.get(f"/api/v1/i/{image_id}/file")
assert file_resp.status_code == 404
@@ -63,7 +62,7 @@ async def test_delete_removes_storage_object(authed_client):
async def test_delete_unknown_id_returns_404(authed_client):
client, token = authed_client
response = await client.delete(
f"/api/v1/images/{uuid.uuid4()}",
"/api/v1/i/NotFound",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 404
@@ -85,12 +84,12 @@ async def test_delete_removes_thumbnail(authed_client):
headers=headers,
)
assert upload.status_code == 201
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
assert upload.json()["thumbnail_key"] is not None
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
assert delete_resp.status_code == 204
thumb_resp = await client.get(f"/api/v1/images/{image_id}/thumbnail")
thumb_resp = await client.get(f"/api/v1/i/{image_id}/thumbnail")
assert thumb_resp.status_code == 404
assert thumb_resp.json()["code"] == "image_not_found"

View File

@@ -3,7 +3,6 @@ Tests that write endpoints require authentication (US2).
These use the authed_client fixture which wires JWTAuthProvider.
"""
import io
import uuid
import pytest
@@ -42,8 +41,7 @@ async def test_upload_with_valid_token_succeeds(authed_client):
@pytest.mark.asyncio
async def test_delete_without_token_returns_401(authed_client):
client, _ = authed_client
fake_id = uuid.uuid4()
response = await client.delete(f"/api/v1/images/{fake_id}")
response = await client.delete("/api/v1/i/NotFound")
assert response.status_code == 401
assert response.json().get("code") == "unauthorized"
@@ -57,9 +55,9 @@ async def test_delete_with_valid_token_succeeds(authed_client):
files={"file": ("del-protected.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
response = await client.delete(
f"/api/v1/images/{image_id}",
f"/api/v1/i/{image_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 204
@@ -68,9 +66,8 @@ async def test_delete_with_valid_token_succeeds(authed_client):
@pytest.mark.asyncio
async def test_patch_tags_without_token_returns_401(authed_client):
client, _ = authed_client
fake_id = uuid.uuid4()
response = await client.patch(
f"/api/v1/images/{fake_id}/tags",
"/api/v1/i/NotFound/tags",
json={"tags": ["a"]},
)
assert response.status_code == 401
@@ -86,9 +83,9 @@ async def test_patch_tags_with_valid_token_succeeds(authed_client):
files={"file": ("tag-protected.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
response = await client.patch(
f"/api/v1/images/{image_id}/tags",
f"/api/v1/i/{image_id}/tags",
json={"tags": ["protected-tag"]},
headers={"Authorization": f"Bearer {token}"},
)

View File

@@ -30,8 +30,8 @@ async def test_get_image_without_token_is_200(authed_client):
files={"file": ("pub-test.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}")
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/i/{image_id}")
assert response.status_code == 200
@@ -44,8 +44,8 @@ async def test_serve_file_without_token_is_200(authed_client):
files={"file": ("pub-file.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}/file")
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/i/{image_id}/file")
assert response.status_code == 200
@@ -58,8 +58,8 @@ async def test_serve_thumbnail_without_token_is_200(authed_client):
files={"file": ("pub-thumb.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
assert response.status_code == 200

View File

@@ -1,10 +1,9 @@
"""
T055 — GET /api/v1/images/{id}/file → 200 with binary content, ETag, Cache-Control
T055 — GET /api/v1/i/{short_id}/file → 200 with binary content, ETag, Cache-Control
T056 — /file for unknown ID → 404 image_not_found
T057 — /file response exposes no storage-specific details
"""
import io
import uuid
import pytest
from PIL import Image as PILImage
@@ -39,10 +38,10 @@ async def test_file_returns_200_with_content(authed_client):
)
assert upload.status_code in (200, 201)
upload_body = upload.json()
image_id = upload_body["id"]
image_id = upload_body["short_id"]
image_hash = upload_body["hash"]
response = await client.get(f"/api/v1/images/{image_id}/file")
response = await client.get(f"/api/v1/i/{image_id}/file")
assert response.status_code == 200
assert response.headers["content-type"].startswith("image/")
assert response.headers["etag"] == f'"{image_hash}"'
@@ -52,7 +51,7 @@ async def test_file_returns_200_with_content(authed_client):
@pytest.mark.asyncio
async def test_file_unknown_id_returns_404(client):
response = await client.get(f"/api/v1/images/{uuid.uuid4()}/file")
response = await client.get("/api/v1/i/NotFound/file")
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"
@@ -68,9 +67,9 @@ async def test_file_response_exposes_no_storage_details(authed_client):
headers={"Authorization": f"Bearer {token}"},
)
assert upload.status_code in (200, 201)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/images/{image_id}/file")
response = await client.get(f"/api/v1/i/{image_id}/file")
assert response.status_code == 200
assert "location" not in response.headers
assert "minio" not in response.text.lower()
@@ -89,10 +88,10 @@ async def test_thumbnail_returns_webp(authed_client):
)
assert upload.status_code == 201
body = upload.json()
image_id = body["id"]
image_id = body["short_id"]
image_hash = body["hash"]
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
assert response.status_code == 200
assert response.headers["content-type"] == "image/webp"
assert response.headers["etag"] == f'"{image_hash}"'
@@ -110,15 +109,15 @@ async def test_thumbnail_fallback_returns_original(authed_client, db_session):
headers={"Authorization": f"Bearer {token}"},
)
assert upload.status_code == 201
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
await db_session.execute(
update(Image).where(Image.id == uuid.UUID(image_id)).values(thumbnail_key=None)
update(Image).where(Image.short_id == image_id).values(thumbnail_key=None)
)
await db_session.flush()
db_session.expire_all()
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
assert response.status_code == 200
assert "image/jpeg" in response.headers["content-type"]
assert len(response.content) > 0
@@ -126,7 +125,7 @@ async def test_thumbnail_fallback_returns_original(authed_client, db_session):
@pytest.mark.asyncio
async def test_thumbnail_unknown_id_returns_404(client):
response = await client.get(f"/api/v1/images/{uuid.uuid4()}/thumbnail")
response = await client.get("/api/v1/i/NotFound/thumbnail")
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"

View File

@@ -81,10 +81,10 @@ async def test_patch_replaces_tag_set(authed_client):
data={"tags": "old-tag"},
headers=headers,
)
image_id = r1.json()["id"]
image_id = r1.json()["short_id"]
patch = await client.patch(
f"/api/v1/images/{image_id}/tags",
f"/api/v1/i/{image_id}/tags",
json={"tags": ["new-tag", "another"]},
headers=headers,
)
@@ -104,10 +104,10 @@ async def test_patch_invalid_tag_returns_422(authed_client):
files={"file": ("invalid-tag-test.png", io.BytesIO(data), "image/png")},
headers=headers,
)
image_id = r1.json()["id"]
image_id = r1.json()["short_id"]
patch = await client.patch(
f"/api/v1/images/{image_id}/tags",
f"/api/v1/i/{image_id}/tags",
json={"tags": ["valid", "INVALID TAG WITH SPACES!"]},
headers=headers,
)

View File

@@ -3,10 +3,10 @@ T026 — valid JPEG upload → 201, record in DB, object in MinIO
T027 — same image uploaded twice → 200, duplicate: true, no second MinIO object
T028 — invalid MIME type → 422 invalid_mime_type (error envelope with code field)
T029 — file > MAX_UPLOAD_BYTES → 422 file_too_large
T079GET /api/v1/images/{id} 404 → error envelope shape
T013upload produces short_id; storage_key equals short_id; thumbnail_key = {short_id}-thumb
"""
import io
import uuid
import re
from unittest.mock import patch
import pytest
@@ -111,13 +111,81 @@ async def test_upload_oversized_file_returns_422(authed_client):
@pytest.mark.asyncio
async def test_get_unknown_image_returns_404_with_envelope(client):
response = await client.get(f"/api/v1/images/{uuid.uuid4()}")
response = await client.get("/api/v1/i/NotFound")
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"
assert "detail" in body
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
@pytest.mark.asyncio
async def test_upload_returns_short_id(authed_client):
client, token = authed_client
data = _minimal_jpeg()
response = await client.post(
"/api/v1/images",
files={"file": ("s1.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
assert "short_id" in body
assert _SHORT_ID_RE.match(body["short_id"]), f"short_id invalid: {body['short_id']}"
@pytest.mark.asyncio
async def test_upload_storage_key_equals_short_id(authed_client):
client, token = authed_client
data = _real_jpeg(color=(10, 20, 30))
response = await client.post(
"/api/v1/images",
files={"file": ("s2.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
assert body["storage_key"] == body["short_id"]
@pytest.mark.asyncio
async def test_upload_thumbnail_key_equals_short_id_thumb(authed_client):
client, token = authed_client
data = _real_jpeg(color=(30, 60, 90))
response = await client.post(
"/api/v1/images",
files={"file": ("s3.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
if body["thumbnail_key"] is not None:
assert body["thumbnail_key"] == f"{body['short_id']}-thumb"
@pytest.mark.asyncio
async def test_duplicate_upload_returns_same_short_id(authed_client):
client, token = authed_client
data = _real_jpeg(color=(200, 100, 50))
headers = {"Authorization": f"Bearer {token}"}
r1 = await client.post(
"/api/v1/images",
files={"file": ("dup_short.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert r1.status_code in (200, 201)
r2 = await client.post(
"/api/v1/images",
files={"file": ("dup_short.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert r2.status_code == 200
assert r2.json()["duplicate"] is True
assert r2.json()["short_id"] == r1.json()["short_id"]
@pytest.mark.asyncio
async def test_upload_returns_thumbnail_key(authed_client):
client, token = authed_client
@@ -133,9 +201,9 @@ async def test_upload_returns_thumbnail_key(authed_client):
assert body["thumbnail_key"] is not None
assert body["thumbnail_key"].endswith("-thumb")
assert "file_url" in body
assert body["file_url"].startswith("/api/v1/images/")
assert body["file_url"].startswith("/api/v1/i/")
assert "thumbnail_url" in body
assert body["thumbnail_url"].startswith("/api/v1/images/")
assert body["thumbnail_url"].startswith("/api/v1/i/")
@pytest.mark.asyncio
@@ -177,5 +245,5 @@ async def test_upload_succeeds_when_thumbnail_fails(authed_client):
body = response.json()
assert body["thumbnail_key"] is None
assert "file_url" in body
assert body["file_url"].startswith("/api/v1/images/")
assert body["file_url"].startswith("/api/v1/i/")
assert body["thumbnail_url"] is None

View File

@@ -1,5 +1,3 @@
_BASE_ENV = {
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
"S3_ENDPOINT_URL": "http://localhost:9000",
@@ -26,6 +24,7 @@ def test_settings_load_from_env(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -43,6 +42,7 @@ def test_settings_max_upload_bytes_override(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -55,6 +55,7 @@ def test_settings_jwt_expiry_override(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -67,6 +68,7 @@ def test_api_docs_enabled_default(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -79,6 +81,7 @@ def test_api_docs_enabled_false(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -91,6 +94,7 @@ def test_api_docs_invalid_value_defaults_to_enabled(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()

View File

@@ -1,6 +1,6 @@
import hashlib
from app.utils import compute_sha256
from app.utils import compute_sha256, generate_short_id
def test_sha256_known_bytes():
@@ -19,3 +19,24 @@ def test_sha256_returns_64_char_hex():
result = compute_sha256(b"test data")
assert len(result) == 64
assert all(c in "0123456789abcdef" for c in result)
def test_generate_short_id_length():
assert len(generate_short_id()) == 8
def test_generate_short_id_charset():
result = generate_short_id()
assert all(
c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" for c in result
)
def test_generate_short_id_randomness():
assert generate_short_id() != generate_short_id()
def test_generate_short_id_importable():
from app.utils import generate_short_id as fn
assert callable(fn)

View File

@@ -0,0 +1,110 @@
"""Unit tests for migrate_to_short_ids script logic."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture
def mock_image_null_short_id():
img = MagicMock()
img.id = "img-uuid-1"
img.short_id = None
img.storage_key = "oldhashkey1234567890"
img.thumbnail_key = "oldhashkey1234567890-thumb"
img.mime_type = "image/jpeg"
return img
@pytest.fixture
def mock_image_with_short_id():
img = MagicMock()
img.id = "img-uuid-2"
img.short_id = "AbCd1234"
img.storage_key = "AbCd1234"
img.thumbnail_key = "AbCd1234-thumb"
img.mime_type = "image/jpeg"
return img
@pytest.mark.asyncio
async def test_migrate_processes_image_without_short_id(mock_image_null_short_id):
"""Images with short_id IS NULL are processed: storage copied, DB updated, old keys deleted."""
from scripts.migrate_to_short_ids import migrate_image
storage = MagicMock()
storage.get = AsyncMock(return_value=b"imagedata")
storage.put = AsyncMock()
storage.delete = AsyncMock()
session = MagicMock()
session.execute = AsyncMock()
session.flush = AsyncMock()
old_key = mock_image_null_short_id.storage_key
new_short_id = "NewSh123"
with patch("scripts.migrate_to_short_ids.generate_short_id", return_value=new_short_id):
result = await migrate_image(mock_image_null_short_id, storage, session)
assert result is True
storage.put.assert_any_call(new_short_id, b"imagedata", "image/jpeg")
storage.delete.assert_any_call(old_key)
@pytest.mark.asyncio
async def test_migrate_skips_image_with_short_id(mock_image_with_short_id):
"""Images that already have a short_id are skipped."""
from scripts.migrate_to_short_ids import migrate_image
storage = MagicMock()
session = MagicMock()
result = await migrate_image(mock_image_with_short_id, storage, session)
assert result is False
storage.get.assert_not_called() if hasattr(storage.get, "assert_not_called") else None
@pytest.mark.asyncio
async def test_migrate_continues_on_storage_error(mock_image_null_short_id):
"""If storage copy fails, error is logged and migrate_image returns False without aborting."""
from scripts.migrate_to_short_ids import migrate_image
storage = MagicMock()
storage.get = AsyncMock(side_effect=Exception("storage read error"))
storage.put = AsyncMock()
storage.delete = AsyncMock()
session = MagicMock()
session.execute = AsyncMock()
session.flush = AsyncMock()
with patch("scripts.migrate_to_short_ids.generate_short_id", return_value="ErrSh123"):
result = await migrate_image(mock_image_null_short_id, storage, session)
assert result is False
storage.put.assert_not_called()
@pytest.mark.asyncio
async def test_migrate_summary_counts(mock_image_null_short_id, mock_image_with_short_id):
"""run_migration reports correct migrated and skipped counts."""
from scripts.migrate_to_short_ids import run_migration
storage = MagicMock()
storage.get = AsyncMock(return_value=b"data")
storage.put = AsyncMock()
storage.delete = AsyncMock()
session = MagicMock()
session.execute = AsyncMock()
session.flush = AsyncMock()
images = [mock_image_null_short_id, mock_image_with_short_id]
with patch("scripts.migrate_to_short_ids.generate_short_id", return_value="NewSh999"):
migrated, skipped, failed = await run_migration(images, storage, session)
assert migrated == 1
assert skipped == 1
assert failed == 0

View File

@@ -0,0 +1,59 @@
"""Unit tests for short_id generation, validation, and repository lookup."""
import re
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from app.routers.images import _validate_short_id
from app.utils import generate_short_id
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
def test_validate_short_id_accepts_valid():
_validate_short_id("AbCd1234") # must not raise
def test_validate_short_id_rejects_too_long():
with pytest.raises(HTTPException) as exc:
_validate_short_id("toolong!!")
assert exc.value.status_code == 422
def test_validate_short_id_rejects_too_short():
with pytest.raises(HTTPException) as exc:
_validate_short_id("short")
assert exc.value.status_code == 422
def test_validate_short_id_rejects_invalid_chars():
with pytest.raises(HTTPException) as exc:
_validate_short_id("has spa!")
assert exc.value.status_code == 422
def test_generate_short_id_unique():
ids = {generate_short_id() for _ in range(100)}
assert len(ids) > 90 # collision in 100 draws would be astronomically unlikely
def test_repo_get_by_short_id_uses_correct_field():
"""get_by_short_id selects on Image.short_id, not Image.id."""
import asyncio
from app.repositories.image_repo import ImageRepository
mock_session = MagicMock()
scalar = MagicMock()
scalar.scalar_one_or_none = MagicMock(return_value=None)
mock_session.execute = AsyncMock(return_value=scalar)
repo = ImageRepository(mock_session)
asyncio.get_event_loop().run_until_complete(repo.get_by_short_id("AbCd1234"))
call_args = mock_session.execute.call_args[0][0]
compiled = call_args.compile(compile_kwargs={"literal_binds": True})
assert "short_id" in str(compiled)
assert "AbCd1234" in str(compiled)

View File

@@ -2,17 +2,21 @@
T037 — tag normalisation: uppercase → lowercase, whitespace stripped
T038 — tag validation: rejects names > 64 chars, invalid chars
"""
import pytest
from app.repositories.tag_repo import TagRepository
@pytest.mark.parametrize("raw,expected", [
("Cat", "cat"),
(" funny ", "funny"),
("REACTION", "reaction"),
(" MiXeD ", "mixed"),
])
@pytest.mark.parametrize(
"raw,expected",
[
("Cat", "cat"),
(" funny ", "funny"),
("REACTION", "reaction"),
(" MiXeD ", "mixed"),
],
)
def test_normalise_lowercases_and_strips(raw, expected):
assert TagRepository.normalise(raw) == expected

View File

@@ -1,4 +1,5 @@
"""Unit tests for thumbnail generation utility."""
import io
from PIL import Image as PILImage

View File

@@ -1,14 +1,13 @@
import uuid
from unittest.mock import MagicMock
import pytest
from app.routers.images import _image_to_dict
def _make_image(*, thumbnail_key=None):
img = MagicMock()
img.id = uuid.UUID("00000000-0000-0000-0000-000000000001")
img.short_id = "AbCd1234"
img.hash = "abc123"
img.filename = "test.jpg"
img.mime_type = "image/jpeg"
@@ -27,6 +26,7 @@ def test_cdn_configured_with_thumbnail():
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"
assert result["short_id"] == "AbCd1234"
def test_cdn_configured_no_thumbnail():
@@ -34,19 +34,20 @@ def test_cdn_configured_no_thumbnail():
result = _image_to_dict(img, cdn_base="https://cdn.example.com")
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
assert result["thumbnail_url"] is None
assert result["short_id"] == "AbCd1234"
def test_no_cdn_with_thumbnail():
img = _make_image(thumbnail_key="abc123storagekey-thumb")
result = _image_to_dict(img, cdn_base=None)
assert result["file_url"] == "/api/v1/images/00000000-0000-0000-0000-000000000001/file"
assert result["thumbnail_url"] == "/api/v1/images/00000000-0000-0000-0000-000000000001/thumbnail"
assert result["file_url"] == "/api/v1/i/AbCd1234/file"
assert result["thumbnail_url"] == "/api/v1/i/AbCd1234/thumbnail"
def test_no_cdn_no_thumbnail():
img = _make_image(thumbnail_key=None)
result = _image_to_dict(img, cdn_base=None)
assert result["file_url"] == "/api/v1/images/00000000-0000-0000-0000-000000000001/file"
assert result["file_url"] == "/api/v1/i/AbCd1234/file"
assert result["thumbnail_url"] is None
@@ -63,3 +64,9 @@ def test_cdn_trailing_whitespace_normalised():
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"
def test_short_id_in_response():
img = _make_image()
result = _image_to_dict(img, cdn_base=None)
assert result["short_id"] == "AbCd1234"

View File

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

View File

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

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View 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`.

View 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.

View 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.

View 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.

View 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`.

View 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.

View 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. T001T005 — toast infrastructure
2. T006T007 — 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.

View File

View File

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

View File

@@ -0,0 +1,115 @@
# Contract: Image API (Short ID Update)
## ImageRecord Response Schema
All image endpoints return this shape. `short_id` is a new field; all other fields are unchanged.
```json
{
"id": "7343d164-80bb-473b-b239-717f2842ae4e",
"short_id": "xK7mN2pQ",
"hash": "163dec08460650439f1e7439721e8e566aff7d8aaad60cf451e7d3518a334a23",
"filename": "image.gif",
"mime_type": "image/gif",
"size_bytes": 1957149,
"width": 265,
"height": 199,
"storage_key": "xK7mN2pQ",
"thumbnail_key": "xK7mN2pQ-thumb",
"file_url": "https://cdn.reactbin.juggalol.com/xK7mN2pQ",
"thumbnail_url": "https://cdn.reactbin.juggalol.com/xK7mN2pQ-thumb",
"created_at": "2026-05-09T02:46:29.520296+00:00",
"tags": ["kfc"]
}
```
**Constraints**:
- `short_id`: exactly 8 alphanumeric characters `[a-zA-Z0-9]{8}`
- `storage_key`: equals `short_id` (post-migration)
- `thumbnail_key`: equals `{short_id}-thumb` or `null` if no thumbnail exists
- `file_url`: `{cdn_base}/{short_id}` when CDN is configured; `/api/v1/images/{short_id}/file` otherwise
- `thumbnail_url`: `{cdn_base}/{short_id}-thumb` or `null`
---
## Route Changes
All routes that previously accepted `{image_id}` as a UUID now accept `{short_id}` as an 8-character alphanumeric string.
### GET /api/v1/images/{short_id}
Fetch a single image by short ID.
- **Path param**: `short_id` — 8-char alphanumeric string
- **Response 200**: ImageRecord
- **Response 404**: `{"detail": "Image not found", "code": "image_not_found"}`
- **Response 422**: `{"detail": "Invalid image ID", "code": "invalid_short_id"}` if param is not 8 alphanumeric chars
### PATCH /api/v1/images/{short_id}/tags
Update tags on an image. Auth required.
- **Path param**: `short_id` — 8-char alphanumeric string
- **Body**: `{"tags": ["tag1", "tag2"]}`
- **Response 200**: ImageRecord (updated)
- **Response 404/422**: same shape as above
### DELETE /api/v1/images/{short_id}
Delete an image and its storage objects. Auth required.
- **Path param**: `short_id` — 8-char alphanumeric string
- **Response 204**: no body
- **Response 404**: error envelope
### GET /api/v1/images/{short_id}/file
Serve the raw image file (proxy mode, when CDN is not configured).
- **Path param**: `short_id`
- **Response 200**: raw image bytes with correct `Content-Type`
### GET /api/v1/images/{short_id}/thumbnail
Serve the thumbnail (proxy mode).
- **Path param**: `short_id`
- **Response 200**: WebP bytes or original image if no thumbnail
### POST /api/v1/images (upload — unchanged route, updated response)
- **Response**: ImageRecord with `short_id` populated
---
## Frontend Route Change
| Old route | New route |
|-----------------|--------------|
| `/images/:id` | `/i/:id` |
The `:id` segment now contains the `short_id` value (8 alphanumeric chars) rather than a UUID.
---
## ImageRecord TypeScript Interface (updated)
```typescript
export interface ImageRecord {
id: string; // UUID — retained, not used for routing
short_id: string; // NEW — 8-char base62, used for all routing and API calls
hash: string;
filename: string;
mime_type: string;
size_bytes: number;
width: number;
height: number;
storage_key: string;
thumbnail_key: string | null;
file_url: string;
thumbnail_url: string | null;
created_at: string;
tags: string[];
duplicate?: boolean;
}
```

View File

@@ -0,0 +1,77 @@
# Data Model: Short Image IDs
## Changed Entity: Image
### New Column
| Column | Type | Constraints | Notes |
|------------|--------------|------------------------------|-------------------------------------------|
| `short_id` | VARCHAR(8) | UNIQUE, NOT NULL (post-migration), INDEX | Base62 alphanumeric, 8 characters |
### Updated Columns (values change, types unchanged)
| Column | Old values | New values |
|-----------------|-----------------------------------------|-----------------------------------|
| `storage_key` | SHA-256 hash (64 hex chars) | short_id (8 base62 chars) |
| `thumbnail_key` | `{hash}-thumb` (69 chars) | `{short_id}-thumb` (13 chars) |
### Unchanged Columns
| Column | Notes |
|------------|-----------------------------------------------------------------------|
| `id` | UUID primary key — unchanged, retained as internal identifier |
| `hash` | SHA-256 content hash — unchanged, still used for deduplication |
| `filename` | Unchanged |
| `mime_type`| Unchanged |
| `size_bytes`, `width`, `height` | Unchanged |
| `created_at` | Unchanged |
### Validation Rules
- `short_id`: exactly 8 characters, matching `[a-zA-Z0-9]{8}` — generated on insert, never updated
- `short_id` must be unique across all image records
- On collision (rare), a new value is generated and retried (up to 10 attempts)
---
## Alembic Migrations
### Migration 003 — Add `short_id` column (nullable)
```
ALTER TABLE images ADD COLUMN short_id VARCHAR(8) NULL;
CREATE UNIQUE INDEX ix_images_short_id ON images (short_id);
```
Run immediately on deploy. Existing rows get `short_id = NULL`. New uploads will populate `short_id` on insert (application-level).
### Migration Script — Backfill existing rows
`api/scripts/migrate_to_short_ids.py`
For each image where `short_id IS NULL`:
1. Generate 8-char base62 short_id (retry on collision)
2. Copy storage object: `{hash}``{short_id}` (S3 copy)
3. Copy thumbnail if present: `{hash}-thumb``{short_id}-thumb`
4. Verify new objects exist (S3 head_object)
5. Update DB row: `short_id = {short_id}`, `storage_key = {short_id}`, `thumbnail_key = {short_id}-thumb` (or NULL)
6. Delete old storage objects
### Migration 004 — Add NOT NULL constraint
```
ALTER TABLE images ALTER COLUMN short_id SET NOT NULL;
```
Run only after the migration script completes successfully with zero `short_id IS NULL` rows remaining.
---
## Storage Object Naming Convention
| Object type | Key pattern | Example |
|-------------|---------------------|-------------------|
| Original | `{short_id}` | `xK7mN2pQ` |
| Thumbnail | `{short_id}-thumb` | `xK7mN2pQ-thumb` |
No folder structure. Flat bucket layout (unchanged from current convention).

View File

@@ -0,0 +1,198 @@
# Implementation Plan: Short Image IDs
**Branch**: `017-short-id-migration` | **Date**: 2026-05-09 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/017-short-id-migration/spec.md`
## Summary
Replace hash-based storage keys and UUID-based URL routing with 8-character base62 short IDs. The short ID becomes the canonical identifier in URLs (`/i/:short_id`), storage keys (`{short_id}` / `{short_id}-thumb`), and all API responses. Hash-based deduplication is preserved unchanged. A Python migration script handles existing images: generates short IDs, copies storage objects to new keys, updates DB records, deletes old keys.
## Technical Context
**Language/Version**: Python 3.12+ (API), TypeScript strict (UI)
**Primary Dependencies**: FastAPI, SQLAlchemy 2.x async, Alembic, aiobotocore/boto3, Angular (latest stable)
**Storage**: PostgreSQL (DB), S3-compatible via boto3 (MinIO local / CDN in prod)
**Testing**: pytest + pytest-asyncio (API unit + integration), Karma/Jasmine (Angular)
**Target Platform**: Linux server (k3s), browser SPA
**Project Type**: Web application (FastAPI API + Angular SPA)
**Performance Goals**: Migration script should process all existing images without timeout; no user-facing performance change
**Constraints**: Migration must be idempotent; no data loss; copy-before-delete for all storage operations
**Scale/Scope**: Personal collection (~hundreds to low thousands of images); collision probability negligible
## Constitution Check
*GATE: Must pass before implementation.*
| Principle | Status | Notes |
|-----------|--------|-------|
| §2.5 DB abstraction — all queries through repos | ✅ PASS | New `get_by_short_id()` added to `ImageRepository`; no raw SQL outside repo |
| §2.6 No speculative abstraction | ✅ PASS | `generate_short_id()` is a concrete utility; no new interfaces |
| §3.1 Routes prefixed `/api/v1/` | ✅ PASS | All routes remain under `/api/v1/images/` |
| §3.1 Adding fields is non-breaking | ✅ PASS | `short_id` is additive; `id` UUID retained |
| §4.2 Images immutable after upload | ✅ PASS | File content is copied, not replaced; the operation changes the storage key, not the bytes |
| §4.3 Deduplication by content hash | ✅ PASS | `hash` column retained; `get_by_hash` unchanged |
| §5.1 Tests alongside every implementation task | ✅ PASS | Each task includes tests |
| §5.2 Integration tests use real PostgreSQL + MinIO | ✅ PASS | Existing integration test infrastructure reused |
| §8 Scope boundaries | ✅ PASS | No multi-user, no public sharing feature, no OR/NOT tag logic |
**No violations. Implementation may proceed.**
## Project Structure
### Documentation (this feature)
```text
specs/017-short-id-migration/
├── plan.md ← this file
├── research.md ← short ID generation, migration strategy
├── data-model.md ← Image schema changes, Alembic migrations
├── contracts/
│ └── image-api.md ← updated ImageRecord schema, route changes
├── quickstart.md ← manual test scenarios
└── tasks.md ← generated by /speckit-tasks
```
### Source Code Changes
```text
api/
├── app/
│ ├── models.py # Add Image.short_id column
│ ├── utils.py # Add generate_short_id()
│ ├── repositories/
│ │ └── image_repo.py # Add get_by_short_id(), update create()
│ └── routers/
│ └── images.py # Path params uuid→str, add short_id to response
├── alembic/versions/
│ ├── 003_add_short_id.py # ADD COLUMN short_id VARCHAR(8) NULLABLE UNIQUE
│ └── 004_short_id_not_null.py # SET NOT NULL (run after migration script)
├── scripts/
│ └── migrate_to_short_ids.py # Backfill existing images
└── tests/
├── unit/
│ ├── test_hashing.py # Add generate_short_id() tests
│ ├── test_url_construction.py # Update mock images to include short_id
│ └── test_short_id.py # NEW: collision retry, charset validation
└── integration/
├── test_upload.py # Assert short_id in response
├── test_search.py # Update {id} → {short_id} in route calls
├── test_delete.py # Update route params
├── test_serving.py # Update route params
└── test_tags.py # Update route params
ui/src/app/
├── app.routes.ts # 'images/:id' → 'i/:id'
├── services/
│ └── image.service.ts # Add short_id to ImageRecord, update service calls
├── library/
│ └── library.component.ts # Navigate to ['/i', img.short_id]
├── upload/
│ └── upload.component.ts # Navigate to ['/i', res.short_id] after upload
└── detail/
└── detail.component.ts # (no route change needed; reads :id param same way)
```
**Structure Decision**: Existing web application layout. API changes are concentrated in models, repository, router, and a new migration script. UI changes are confined to routes, image service interface, and two navigation calls.
## Implementation Phases
### Phase 1: Backend — Short ID Infrastructure
1. Add `generate_short_id()` to `api/app/utils.py`
- Base62 charset: `string.ascii_letters + string.digits`
- Uses `secrets.choice` for cryptographic randomness
- Returns 8-character string
2. Add Alembic migration `003_add_short_id.py`
- `ADD COLUMN short_id VARCHAR(8) NULL`
- `CREATE UNIQUE INDEX ix_images_short_id ON images (short_id)`
3. Update `api/app/models.py`
- Add `short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True)`
4. Update `api/app/repositories/image_repo.py`
- Add `get_by_short_id(short_id: str) -> Image | None`
- Update `create()` to accept and persist `short_id` parameter
5. Update `api/app/routers/images.py`
- Change all `image_id: uuid.UUID` path params to `short_id: str`
- Add `_validate_short_id(short_id: str)` helper (8 alphanumeric chars, else 422)
- Replace `get_by_id` calls with `get_by_short_id`
- Update `_image_to_dict` to include `"short_id": image.short_id` in response
- Update upload handler: generate `short_id` with collision retry, use as storage key
### Phase 2: Migration Script
`api/scripts/migrate_to_short_ids.py`:
```
for each image where short_id IS NULL:
generate short_id (retry on DB collision)
copy {hash} → {short_id} in storage
if thumbnail_key IS NOT NULL:
copy {hash}-thumb → {short_id}-thumb in storage
verify new objects exist (head_object)
UPDATE images SET short_id={sid}, storage_key={sid}, thumbnail_key={sid}-thumb WHERE id={id}
delete {hash} from storage
if thumbnail_key was not null:
delete {hash}-thumb from storage
log: "migrated {id} → {short_id}"
print summary: N migrated, M skipped (already had short_id)
```
After script runs with 0 remaining `NULL` short_ids, apply migration `004_short_id_not_null.py`.
### Phase 3: Frontend
1. `app.routes.ts`: `path: 'images/:id'``path: 'i/:id'`
2. `image.service.ts`: add `short_id: string` to `ImageRecord`
3. `library.component.ts`: `router.navigate(['/images', img.id])``router.navigate(['/i', img.short_id])`
4. `upload.component.ts`: `router.navigate(['/images', res.id])``router.navigate(['/i', res.short_id])`
### Phase 4: Polish
- Update all existing API integration tests to use `short_id` in route paths
- Run `ng lint` and `ruff check` across modified files
- Verify `ng build --configuration production` succeeds
- Run full test suites: `make test-unit && make test-integration`
## Key Implementation Notes
### Collision Retry Pattern (upload)
```python
MAX_RETRIES = 10
for attempt in range(MAX_RETRIES):
short_id = generate_short_id()
try:
image = await image_repo.create(..., short_id=short_id)
break
except IntegrityError: # short_id collision
await db.rollback()
if attempt == MAX_RETRIES - 1:
raise RuntimeError("Could not generate unique short_id")
```
### Route Validation
```python
import re
_SHORT_ID_RE = re.compile(r'^[a-zA-Z0-9]{8}$')
def _validate_short_id(short_id: str) -> None:
if not _SHORT_ID_RE.match(short_id):
raise HTTPException(422, detail={"detail": "Invalid image ID", "code": "invalid_short_id"})
```
### `_image_to_dict` Update
Add `"short_id": image.short_id` to the returned dict. The `file_url` and `thumbnail_url` generation already uses `image.storage_key` which will now equal `image.short_id` — no formula change needed.
### Migration Script Entry Point
```bash
cd api && python -m scripts.migrate_to_short_ids
```
Reads DB URL and storage config from environment variables (same as the application).

View File

@@ -0,0 +1,73 @@
# Quickstart: Short Image IDs
## Scenario 1 — Happy Path: New Upload Gets Short ID
1. Log in and navigate to Upload.
2. Upload any image.
3. Observe: browser navigates to `/i/AbCdEfGh` (8-char short ID, not a UUID).
4. Copy the URL from the address bar and paste in a new tab — image loads correctly.
5. Open the URL in a private/incognito window (not logged in) — image still loads.
**Pass criteria**: URL is `/i/{8 alphanumeric chars}`, image loads authenticated and unauthenticated.
---
## Scenario 2 — Deduplication Still Works
1. Upload any image — note the short ID in the URL.
2. Upload the exact same file again.
3. Observe: API returns `duplicate: true`, browser navigates to the same short ID URL as step 1.
**Pass criteria**: No second record created, same short ID returned.
---
## Scenario 3 — Library Navigation Uses Short IDs
1. Open the library (`/`).
2. Click any image card.
3. Observe: navigated to `/i/{short_id}`, not `/images/{uuid}`.
**Pass criteria**: All image card clicks navigate to `/i/` routes.
---
## Scenario 4 — Tag and Delete Operations Work via Short ID
1. Open an image detail page at `/i/{short_id}`.
2. If logged in: add a tag, remove a tag — confirm both succeed.
3. If logged in: delete the image — confirm navigates back to library, image no longer appears.
**Pass criteria**: Tag updates and delete work correctly when the route uses a short ID.
---
## Scenario 5 — Migration: All Existing Images Accessible
1. After running the migration script: open the library.
2. Click through several images from before the migration.
3. Observe: all navigate to `/i/{short_id}` URLs, all images and thumbnails load.
4. No broken image placeholders visible.
**Pass criteria**: 100% of pre-migration images accessible via short ID with no broken assets.
---
## Scenario 6 — Migration Script Is Idempotent
1. Run the migration script once — note how many images were migrated.
2. Run the migration script a second time.
3. Observe: script reports 0 images migrated (all already have short IDs), exits cleanly.
**Pass criteria**: Second run produces no DB changes, no storage operations, no errors.
---
## Scenario 7 — Copy URL Button Copies Short Page URL
1. Open any image detail page at `/i/{short_id}`.
2. Click "Copy URL".
3. Paste into a text editor.
4. Observe: pasted value is the CDN file URL (e.g. `https://cdn.reactbin.juggalol.com/xK7mN2pQ`), not a UUID-based URL.
**Pass criteria**: Copied URL contains the short_id, not a UUID.

View File

@@ -0,0 +1,56 @@
# Research: Short Image IDs
## Short ID Generation
**Decision**: Use `secrets.choice` over `string.ascii_letters + string.digits` (base62, 62 characters), 8 characters long.
**Rationale**: `secrets.choice` is cryptographically random, eliminating any bias from modular reduction that affects simpler approaches. Base62 (az, AZ, 09) is URL-safe without percent-encoding. 8 characters gives 62⁸ ≈ 218 trillion combinations — negligible collision probability even at millions of images.
**Alternatives considered**:
- `secrets.token_urlsafe(6)` — includes `-` and `_`, not pure alphanumeric
- UUID truncation (first 8 chars of hex) — only 16 chars of alphabet (hex), dramatically fewer combinations than base62
- nanoid (npm) — JavaScript library, requires a separate dependency for Python
**Collision retry**: On insert, if a `UniqueConstraint` violation is raised on `short_id`, generate a new one and retry (up to a configurable limit, e.g., 10 attempts). At 10,000 images the per-attempt collision probability is ~4.6 × 10⁻¹¹; retries are a pure safety measure.
---
## Alembic Two-Phase Migration Strategy
**Decision**: Two separate Alembic migrations (003 + 004), with the Python migration script run between them.
**Rationale**: The `short_id` column must start nullable so existing rows can be inserted without a value. The migration script fills all existing rows. Once confirmed, a second migration adds the NOT NULL constraint. Running both as one migration would require a complex inline Python script in Alembic (fragile, untestable). Two migrations with a script in between is the standard approach for backfill + constraint change.
**Migration 003**: `ADD COLUMN short_id VARCHAR(8) NULL UNIQUE` + GiST/B-tree index.
**Script**: Fill all rows, idempotent (skip rows where `short_id IS NOT NULL`).
**Migration 004**: `ALTER COLUMN short_id SET NOT NULL`.
---
## Storage Object Copy Strategy
**Decision**: Copy-then-verify-then-delete (not atomic rename). Using the MinIO/S3 `copy_object` API followed by a `delete_object` call.
**Rationale**: S3-compatible object stores do not support atomic renames. The safe approach is: copy to new key, verify new object exists (head_object), update DB, delete old key. If interrupted after copy but before delete, the old object remains — wasted storage but no data loss. The migration is idempotent: if `short_id` is already set on a row, the script skips it.
**Alternatives considered**:
- `mc mv` (MinIO client CLI) — simpler but harder to script transactionally with DB updates
- Direct Python with `aiobotocore` — chosen; same library already used by the storage backend
---
## API Route Parameter Change
**Decision**: Change all image route parameters from `image_id: uuid.UUID` to `short_id: str` with manual length/charset validation.
**Rationale**: FastAPI's `uuid.UUID` type annotation rejects non-UUID strings at the path-parsing stage, so the existing routes cannot accept short IDs without a type change. Switching to `str` with a custom validator (8 alphanumeric chars) is minimal and clear.
**Impact**: All routes under `/api/v1/images/{id}` change to accept an 8-char string. The `id` field in API responses is retained as the UUID; `short_id` is added as a new field. The UI switches to using `short_id` for all navigation and API calls.
---
## Response Schema: Additive Change
**Decision**: Add `short_id` as a new field to the image response dict. The existing `id` (UUID) field is retained.
**Rationale**: Adding a field is non-breaking per §3.1. Removing `id` would be a breaking change. Retaining both allows any internal tooling or API consumers that already use `id` to continue working. The UI transitions to using `short_id` for routing and API calls, but the UUID remains queryable if needed.

View File

@@ -0,0 +1,104 @@
# Feature Specification: Short Image IDs
**Feature Branch**: `017-short-id-migration`
**Created**: 2026-05-09
**Status**: Draft
**Input**: User description: "Replace UUID-based image identifiers with 8-character base62 short IDs. Short IDs become the canonical identifier in URLs (/i/:short_id replacing /images/:uuid), MinIO storage keys, and all API responses. Existing hash-based deduplication is preserved. Migration includes backfilling short IDs for existing images, renaming storage objects, and regenerating file URLs. Frontend routes update to use short IDs throughout."
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Clean, Shareable Image Links (Priority: P1)
A user wants to share an image with someone. They copy the page URL or use the "Copy URL" button and get a short, clean link they can paste anywhere. The link is brief enough to share in a message without looking like machine-generated noise.
**Why this priority**: This is the primary user-facing value of the change. Every image in the library benefits immediately. Short links are more trustworthy, easier to share, and less likely to break in messaging apps that truncate long URLs.
**Independent Test**: Open any image detail page. Confirm the URL in the browser address bar is short (e.g. `/i/AbCdEfGh`). Copy the URL and paste it into a new tab — confirm the correct image loads. Share the link with someone who is not logged in and confirm they can view the image.
**Acceptance Scenarios**:
1. **Given** a user is on the image detail page, **When** they look at the browser address bar, **Then** the URL contains a short 8-character identifier rather than a long UUID.
2. **Given** a short image URL, **When** an unauthenticated user opens it, **Then** the image loads correctly without requiring login.
3. **Given** a short image URL, **When** it is pasted into a messaging app or email, **Then** it is compact enough to read at a glance and does not get truncated.
---
### User Story 2 — New Uploads Assigned Short IDs (Priority: P2)
When a new image is uploaded, the system assigns it a short ID immediately. The image is accessible via its short URL straight away. If the same file has already been uploaded before, the existing record is returned rather than creating a duplicate — the deduplication behaviour is unchanged.
**Why this priority**: This ensures the new convention is in place going forward. Without this, the migration work in US3 would need to be re-run for any new uploads.
**Independent Test**: Upload a new image. Confirm the detail page URL contains an 8-character short ID. Upload the exact same file again — confirm no new record is created and the existing short URL is returned.
**Acceptance Scenarios**:
1. **Given** a user uploads an image, **When** the upload completes, **Then** the image is accessible at a short URL (`/i/{short_id}`).
2. **Given** a user uploads a file that is identical to a previously uploaded image, **When** the upload completes, **Then** the system returns the existing image's short URL rather than creating a duplicate entry.
3. **Given** a newly uploaded image, **When** the "Copy URL" button is used, **Then** the copied link is the short image page URL.
---
### User Story 3 — All Existing Images Migrated to Short IDs (Priority: P3)
All images that existed before this change are assigned short IDs and remain fully accessible. Their stored files are renamed to match the new convention. After migration, all image links throughout the application use short IDs — no UUID-based links remain active.
**Why this priority**: Without migration, legacy images would either be inaccessible or require maintaining two parallel URL schemes. Clean cutover is preferable. This is lower priority than P1/P2 because it is an administrative operation rather than a user-facing feature, but it must complete before the feature can be considered fully shipped.
**Independent Test**: After running the migration, browse the library and open several images — confirm all detail pages use short URLs. Confirm no broken images or missing thumbnails.
**Acceptance Scenarios**:
1. **Given** images that existed before the migration, **When** the migration completes, **Then** all are accessible via short URLs.
2. **Given** the migration has run, **When** a user browses the library and opens any image, **Then** the detail page URL is a short ID URL.
3. **Given** the migration has run, **When** any image or thumbnail is displayed, **Then** it loads correctly with no broken images.
4. **Given** the migration is running, **When** it encounters an error on one image, **Then** it reports the failure clearly and continues processing remaining images rather than aborting entirely.
---
### Edge Cases
- What happens if a short ID collision occurs during generation? The system must retry with a new ID rather than failing or overwriting an existing image.
- What happens if a record lacks a short ID but the file content is unchanged? The migration assigns a new short ID without re-uploading the file.
- What happens if the migration is interrupted partway through? Already-migrated images remain accessible; un-migrated images are identifiable so the migration can be re-run safely.
- What happens if a thumbnail does not exist for an image (e.g., GIFs where generation failed)? The migration skips the thumbnail rename for that record and continues.
- What happens if a user has bookmarked a UUID-based URL before the migration? Those URLs become invalid; this is acceptable for a personal tool with no external consumers.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST assign every image a unique 8-character short ID composed of alphanumeric characters (az, AZ, 09).
- **FR-002**: Every image detail page MUST be accessible at the path `/i/{short_id}`.
- **FR-003**: The UUID-based image detail route (`/images/{uuid}`) MUST be retired; short ID routes are the sole canonical paths.
- **FR-004**: Image storage objects (original and thumbnail) MUST use the short ID as their storage key, following flat naming: `{short_id}` for the original and `{short_id}-thumb` for the thumbnail.
- **FR-005**: The publicly accessible image file URL and thumbnail URL MUST reflect the new storage key names.
- **FR-006**: On upload, the system MUST check whether an identical file (by hash) already exists and return the existing record rather than creating a duplicate, regardless of short IDs.
- **FR-007**: The system MUST generate a new short ID on upload, retrying automatically if a collision with an existing ID is detected.
- **FR-008**: A migration process MUST assign short IDs to all existing images that do not have one, rename their storage objects to match the new keys, and update all stored URLs.
- **FR-009**: The migration MUST be re-runnable safely — images already migrated MUST be skipped rather than processed again.
- **FR-010**: All application links that reference images (library grid, detail page, API responses) MUST use short IDs after the migration.
### Key Entities
- **Image**: Each image has a unique short ID (8 alphanumeric characters) that serves as its canonical identifier in URLs, storage, and API responses. The image retains its content hash for deduplication. The short ID is independent of the hash.
- **Storage Object**: Each image has two storage objects — an original and a thumbnail — named using the short ID (`{short_id}` and `{short_id}-thumb`). Flat naming, no folder structure.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: All image detail page URLs use an 8-character alphanumeric identifier at `/i/{short_id}`.
- **SC-002**: 100% of existing images are accessible via short URL after migration completes, with no broken images or missing thumbnails.
- **SC-003**: Uploading the same file twice produces one record — deduplication rate remains 100% for identical files.
- **SC-004**: The migration completes without data loss — no image file or thumbnail is deleted before its renamed copy is confirmed present in storage.
- **SC-005**: The migration is idempotent — running it a second time produces no changes and no errors.
## Assumptions
- UUID-based image URLs do not need to remain accessible after migration; this is a personal tool with no external consumers relying on the old URL structure.
- The migration will be run manually by the operator as a one-time administrative step; it does not need to be triggered from the UI.
- Storage object renaming is implemented as copy-then-delete to avoid data loss if the process is interrupted mid-run.
- The short ID character set is base62 (az, AZ, 09); no special characters, ensuring URL-safe identifiers without percent-encoding.
- The `hash` column is retained and continues to be used for deduplication; it is not removed as part of this change.
- Thumbnails may not exist for all images (e.g., some GIFs); the migration handles missing thumbnails gracefully by skipping the thumbnail rename for those records.

View File

@@ -0,0 +1,162 @@
# Tasks: Short Image IDs
**Input**: Design documents from `specs/017-short-id-migration/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/image-api.md ✅, quickstart.md ✅
**Tests**: Tests accompany each implementation task per §5.1. All API changes are in `api/`, all UI changes are in `ui/src/app/`.
**Organization**: The foundational phase (Phase 1) must complete before any user story work begins — it adds the `short_id` column, model field, utility function, and repository method that all three user stories depend on. US1 and US2 can then proceed; US3 (migration script) follows last because it operates on the fully wired system.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to
---
## Phase 1: Foundational — Short ID Infrastructure (Blocks All User Stories)
**Goal**: Add the `short_id` column to the database, the model, a generation utility, and a repository lookup. Every user story depends on these.
**Independent Test**: After this phase, `generate_short_id()` can be called from a Python shell and returns an 8-character alphanumeric string. `alembic upgrade head` applies migration 003 cleanly. A manually inserted image with a `short_id` can be fetched by `image_repo.get_by_short_id()` in a test.
- [X] T001 Write failing unit tests for `generate_short_id()` in `api/tests/unit/test_hashing.py`: (1) returns exactly 8 characters; (2) contains only `[a-zA-Z0-9]` characters; (3) two consecutive calls return different values (collision test); (4) function exists and is importable from `app.utils`. Run `make test-unit` and confirm new tests FAIL.
- [X] T002 Add `generate_short_id()` to `api/app/utils.py`: import `secrets` and `string`; define `BASE62 = string.ascii_letters + string.digits`; implement `def generate_short_id(length: int = 8) -> str: return ''.join(secrets.choice(BASE62) for _ in range(length))`. Run `make test-unit` and confirm T001 tests pass.
- [X] T003 Create Alembic migration `api/alembic/versions/003_add_short_id.py`: `op.add_column('images', sa.Column('short_id', sa.String(8), nullable=True))`; `op.create_index('ix_images_short_id', 'images', ['short_id'], unique=True)`. Downgrade removes index then column. Run `alembic upgrade head` in the api container and confirm migration applies cleanly.
- [X] T004 [P] Add `short_id` field to `Image` model in `api/app/models.py`: `short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True)`. No change to column sizes for `storage_key` (keep String(64)) or `thumbnail_key` (keep String(70)) — values will simply be shorter after migration.
- [X] T005 Update `api/app/repositories/image_repo.py`: (a) add `async def get_by_short_id(self, short_id: str) -> Image | None` — SELECT with `Image.short_id == short_id` and `selectinload(Image.image_tags).selectinload(ImageTag.tag)`; (b) add `short_id: str` parameter to `create()` and persist it on the `Image` instance. Write a unit test in `api/tests/unit/` mocking the session to confirm `get_by_short_id` constructs the correct WHERE clause.
- [X] T005a Update `api/tests/integration/conftest.py`: wherever test fixtures call `image_repo.create()` or insert image rows directly, add `short_id=generate_short_id()` (import `generate_short_id` from `app.utils`). This ensures all integration test fixture images have a `short_id` value so that tests referencing `image.short_id` in URLs and assertions work correctly. Run `make test-integration` and confirm existing tests still pass (no new failures introduced).
**Checkpoint**: Short ID infrastructure complete. The `short_id` column exists in DB, `generate_short_id()` works, and the repo can look up images by short_id.
---
## Phase 2: User Story 1 — Clean, Shareable Image Links (Priority: P1) 🎯 MVP
**Goal**: All API routes accept `short_id` (8-char string) instead of UUID. `short_id` appears in every API response. The frontend navigates to `/i/:short_id` and the library uses `short_id` for navigation.
**Independent Test**: Upload a new image (US2 must be done for end-to-end, but US1 can be tested using a fixture image with a known `short_id` inserted directly). Call `GET /api/v1/images/{short_id}` and confirm it returns the correct image with `short_id` in the response. Navigate to `/i/{short_id}` in the browser and confirm the detail page loads.
- [X] T006 Write failing unit tests in `api/tests/unit/test_url_construction.py`: update `_make_image()` mock to include `short_id = 'AbCd1234'`; add assertions that `_image_to_dict` result includes `"short_id": "AbCd1234"`. Write failing unit tests in `api/tests/unit/test_short_id.py`: (1) `_validate_short_id('AbCd1234')` passes; (2) `_validate_short_id('toolong!!')` raises 422; (3) `_validate_short_id('short')` raises 422; (4) `_validate_short_id('has space!')` raises 422. Run `make test-unit` and confirm new tests FAIL.
- [X] T007 Update `api/app/routers/images.py``_image_to_dict`: add `"short_id": image.short_id` to the returned dict (between `"id"` and `"hash"`). Add `_validate_short_id(short_id: str) -> None` helper: compile `re.compile(r'^[a-zA-Z0-9]{8}$')` at module level; raise `HTTPException(422, detail={"detail": "Invalid image ID", "code": "invalid_short_id"})` if no match. Run `make test-unit` and confirm T006 tests pass.
- [X] T008 Update all image route handlers in `api/app/routers/images.py` — change every `image_id: uuid.UUID` path parameter to `short_id: str`; call `_validate_short_id(short_id)` at the start of each handler; replace all `image_repo.get_by_id(image_id)` calls with `image_repo.get_by_short_id(short_id)`. Affected routes: `GET /images/{short_id}`, `GET /images/{short_id}/file`, `GET /images/{short_id}/thumbnail`, `PATCH /images/{short_id}/tags`, `DELETE /images/{short_id}`. Remove `import uuid` if no longer used.
- [X] T009 [P] Write failing Angular tests: (a) in `ui/src/app/services/image.service.ts` — update `MOCK_IMAGE` in `detail.component.spec.ts` and any other spec files to include `short_id: 'AbCd1234'`; (b) in `ui/src/app/library/library.component.spec.ts` — add test asserting that clicking an image card calls `router.navigate` with `['/i', img.short_id]` rather than `['/images', img.id]`. Run `ng test --watch=false` and confirm new tests FAIL.
- [X] T010 Update `ui/src/app/app.routes.ts`: change `path: 'images/:id'` to `path: 'i/:id'`. The `DetailComponent` reads `this.route.snapshot.paramMap.get('id')` — no change needed there since the param name `:id` is unchanged.
- [X] T011 Add `short_id: string` to the `ImageRecord` interface in `ui/src/app/services/image.service.ts`. No changes to method signatures — `get(id)`, `updateTags(id, ...)`, and `delete(id)` already accept `string`; callers will now pass `short_id` values instead of UUIDs.
- [X] T011a Update `ui/src/app/detail/detail.component.ts`: change `this.imageService.updateTags(this.image.id, updated)` (×2, lines ~214 and ~224) and `this.imageService.delete(this.image.id)` (line ~235) to use `this.image.short_id` instead of `this.image.id`. After T008 the API accepts only 8-char short_ids; passing a UUID will trigger a 422. Add assertions to `ui/src/app/detail/detail.component.spec.ts` confirming that `updateTags` and `delete` are called with the `short_id` value (`'AbCd1234'`) not the UUID. Run `ng test --watch=false --include="src/app/detail/detail.component.spec.ts"` and confirm new assertions pass.
- [X] T012 Update `ui/src/app/library/library.component.ts`: change `router.navigate(['/images', img.id])` (×2: click handler and keydown handler) to `router.navigate(['/i', img.short_id])`. Run `ng test --watch=false` and confirm T009 Angular tests pass.
**Checkpoint**: US1 complete. API returns `short_id` on every image response. Routes accept short IDs. Library navigates to `/i/{short_id}`.
---
## Phase 3: User Story 2 — New Uploads Assigned Short IDs (Priority: P2)
**Goal**: When a new image is uploaded, a short ID is generated, used as the storage key (replacing the hash), and returned in the response. Deduplication by content hash still works.
**Independent Test**: Upload a new image. Confirm the response includes a `short_id` field with exactly 8 alphanumeric characters. Confirm `storage_key` equals `short_id` and `thumbnail_key` equals `{short_id}-thumb`. Upload the same image again — confirm `duplicate: true` and the same `short_id` is returned.
- [X] T013 Write failing integration tests in `api/tests/integration/test_upload.py`: (1) upload a new image → response includes `short_id` field matching `[a-zA-Z0-9]{8}`; (2) `storage_key` in response equals `short_id`; (3) `thumbnail_key` in response equals `{short_id}-thumb` (or is null for images without thumbnails); (4) upload same file twice → second response has `duplicate: true` and identical `short_id`. Run `make test-integration` and confirm new tests FAIL.
- [X] T014 Update the upload handler in `api/app/routers/images.py`: after the hash duplicate check, add collision-retry loop (up to 10 attempts): `short_id = generate_short_id()`; call `await storage.put(short_id, data, mime_type)` instead of `await storage.put(hash_hex, ...)`; call `await storage.put(f"{short_id}-thumb", ...)` instead of `f"{hash_hex}-thumb"`; pass `storage_key=short_id`, `thumbnail_key=f"{short_id}-thumb"` (or None), and `short_id=short_id` to `image_repo.create()`. Catch `IntegrityError` on `create()`, rollback, retry with new short_id. Import `generate_short_id` from `app.utils` and `IntegrityError` from `sqlalchemy.exc`. Run `make test-integration` and confirm T013 tests pass.
- [X] T015 Update `ui/src/app/upload/upload.component.ts`: change `this.router.navigate(['/images', res.id])` to `this.router.navigate(['/i', res.short_id])`. Add a test to the upload component spec (or update the existing navigation test) asserting the route uses `short_id`.
**Checkpoint**: US2 complete. All new uploads produce a short ID and are immediately accessible at `/i/{short_id}`.
---
## Phase 4: User Story 3 — All Existing Images Migrated to Short IDs (Priority: P3)
**Goal**: A runnable script backfills `short_id` for all pre-existing images, copies their storage objects to the new key pattern, and updates DB records. A final Alembic migration adds the NOT NULL constraint. After this phase the system has no UUID-keyed storage objects.
**Independent Test**: Run the migration script — confirm it prints a count of migrated images and exits cleanly. Run it a second time — confirm it reports 0 migrated (idempotent). Browse the library and open pre-migration images — confirm all load with short ID URLs and no broken images.
- [X] T016 Write unit tests in `api/tests/unit/test_migration.py` covering the migration script logic: (1) an image with `short_id IS NULL` is processed (short_id generated, storage copy called, DB update called, old keys deleted); (2) an image with `short_id` already set is skipped; (3) if a storage copy fails, the error is logged and the script continues to the next image (no abort); (4) the summary at the end reports correct migrated and skipped counts. Mock the storage client and DB session. Run `make test-unit` and confirm new tests FAIL (script not yet created).
- [X] T017 Create `api/scripts/__init__.py` (empty) and `api/scripts/migrate_to_short_ids.py`: async main function that (a) reads DB URL and storage config from env vars via `app.config.get_settings()`; (b) creates an async DB session and `S3StorageBackend` instance; (c) queries all images where `short_id IS NULL`; (d) for each: generate short_id (retry on `UniqueViolation`), copy storage object using `data = await storage.get(old_key); await storage.put(new_key, data, image.mime_type)` (the `StorageBackend` interface provides only `get`/`put`/`delete` — there is no `copy` method), verify the copy succeeded by calling `await storage.get(new_key)` and catching any exception, update the DB row (`short_id`, `storage_key`, `thumbnail_key`), then delete old keys with `await storage.delete(old_key)`; (e) skips images where `thumbnail_key IS NULL` for the thumbnail copy step; (f) wraps each image in a try/except so a single failure logs an error and continues to the next image; (g) prints `Migrated: N, Skipped: M, Failed: K` on completion. Entry point: `if __name__ == '__main__': asyncio.run(main())`. Run `make test-unit` and confirm T016 tests pass.
- [X] T018 Create Alembic migration `api/alembic/versions/004_short_id_not_null.py`: `op.alter_column('images', 'short_id', nullable=False)`. **Run this migration only after the migration script completes with 0 remaining NULL rows.** Downgrade sets nullable=True. Document this ordering requirement in the migration file's docstring.
**Checkpoint**: US3 complete. All existing images have short IDs, storage objects use new key pattern, `short_id` column is NOT NULL.
---
## Phase 5: Polish & Cross-Cutting Concerns
- [X] T019 Update `api/tests/integration/test_search.py`, `test_delete.py`, `test_serving.py`, `test_tags.py`, and `test_public_access.py`: wherever tests construct a URL with `f"/api/v1/images/{image.id}"` or `f"/api/v1/images/{uuid}"`, replace with `f"/api/v1/images/{image.short_id}"`. Ensure test fixtures (conftest.py) populate `short_id` on images created for testing. Run `make test-integration` and confirm all integration tests pass.
- [X] T020 Update `ui/src/app/detail/detail.component.spec.ts`: add `short_id: 'AbCd1234'` to `MOCK_IMAGE` and `MOCK_IMAGE_ABS` constants. Update any test assertions that check navigation targets to use `short_id`. Run `ng test --watch=false --include="src/app/detail/detail.component.spec.ts"` and confirm all tests pass.
- [X] T021 Run `ng lint` across all modified UI files and `ruff check api/app/ api/tests/ api/scripts/` across all modified API files; fix any issues. Confirm `ruff format --check api/` passes.
- [X] T022 Run `ng build --configuration production` and confirm build succeeds with no TypeScript errors. Run `make test-unit && make test-integration` and confirm all tests pass.
- [ ] T023 Manually verify all seven quickstart.md scenarios in the browser: (1) new upload navigates to `/i/{short_id}`; (2) deduplication returns same short_id; (3) library cards navigate to `/i/`; (4) tag and delete work via short_id; (5) pre-migration images accessible (after running script); (6) migration is idempotent; (7) "Copy URL" copies the CDN URL with short_id.
---
## Dependencies & Execution Order
- T001 before T002 (write failing tests before implementation)
- T002 before T003/T004/T005 (utility must exist before migration and model reference it)
- T003 before T004 (DB column must exist before model references it)
- T004 before T005 (model must have `short_id` field before repo uses it)
- T005 before T005a (conftest fixtures need the updated `create()` signature)
- T005a before T006 (integration test fixtures must have `short_id` before any integration tests run)
- T006 before T007 (write failing tests before implementation)
- T007 before T008 (helper and dict update before route param changes)
- T008 before T009/T010/T011 (API must accept short_id before frontend uses it)
- T009 before T010/T011/T011a/T012 (write failing tests before implementation)
- T010, T011, T011a, T012 can run in parallel (different files)
- T011 before T011a (interface must have `short_id` field before detail component uses it)
- T013 before T014 (write failing tests before upload changes)
- T014 before T015 (upload must produce short_id before frontend navigation uses it)
- T016 before T017 (write failing tests before script)
- T017 before T018 (script must run successfully before NOT NULL migration)
- T019T023 after T018 (polish after all implementation complete)
### Execution Order Summary
```
Step 1: T001 → T002 (generate_short_id: tests → implementation)
Step 2: T003, T004 (parallel) (Alembic 003 + model update)
Step 3: T005 (repo: get_by_short_id + create update)
Step 3a: T005a (conftest: fixture images get short_id)
Step 4: T006 → T007 → T008 (API routes: tests → dict → route params)
Step 5: T009 (Angular failing tests)
Step 6: T010, T011, T011a, T012 (parallel) (route, interface, detail caller fix, library navigation)
Step 7: T013 → T014 → T015 (upload: tests → handler → upload navigation)
Step 8: T016 → T017 → T018 (migration: tests → script → NOT NULL migration)
Step 9: T019, T020 (parallel) (test updates)
Step 10: T021, T022, T023 (lint, build, manual verification)
```
---
## Implementation Strategy
### MVP (US1 + US2 — full feature with new uploads)
1. T001T005a — foundational infrastructure + conftest fixtures
2. T006T012 (including T011a) — API routes accept short_id + frontend uses `/i/`
3. T013T015 — new uploads generate short_id
4. **STOP and VALIDATE**: upload a new image, confirm `/i/{short_id}` URL, confirm browsing works
5. T016T018 — migrate existing images
6. T019T023 — polish
### Note on Priority vs Implementation Order
US1 (P1) and US2 (P2) are implemented together before US3 (P3). The foundational phase is the true prerequisite for all three. US1 and US2 are tightly coupled in practice (you need uploads to produce short IDs before routing can be tested end-to-end), so they are sequenced rather than strictly priority-ordered.

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

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

57
ui/package-lock.json generated
View File

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

View File

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

View File

@@ -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);

View File

@@ -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; }

View File

@@ -24,7 +24,7 @@ export const routes: Routes = [
import('./tags/tags.component').then((m) => m.TagsComponent),
},
{
path: 'images/:id',
path: 'i/:id',
loadComponent: () =>
import('./detail/detail.component').then((m) => m.DetailComponent),
},

View File

@@ -5,14 +5,16 @@ 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 = {
id: 'img-1', hash: 'abc', filename: 'test.jpg', mime_type: 'image/jpeg',
size_bytes: 100, width: 10, height: 10, storage_key: 'abc',
thumbnail_key: null, file_url: '/api/v1/images/img-1/file', thumbnail_url: null,
id: 'img-1', short_id: 'AbCd1234', hash: 'abc', filename: 'test.jpg', mime_type: 'image/jpeg',
size_bytes: 100, width: 10, height: 10, storage_key: 'AbCd1234',
thumbnail_key: null, file_url: '/api/v1/i/AbCd1234/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)) {
@@ -37,14 +39,14 @@ describe('DetailComponent', () => {
const { component, imgSvc } = setup();
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] }));
component.removeTag('cat');
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['funny']);
expect(imgSvc.updateTags).toHaveBeenCalledWith('AbCd1234', ['funny']);
});
it('should call PATCH with new tag included on addTag', () => {
const { component, imgSvc } = setup();
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['cat', 'funny', 'new'] }));
component.addTag('new');
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['cat', 'funny', 'new']);
expect(imgSvc.updateTags).toHaveBeenCalledWith('AbCd1234', ['cat', 'funny', 'new']);
});
it('should call DELETE and navigate to library on confirm delete', () => {
@@ -53,7 +55,7 @@ describe('DetailComponent', () => {
spyOn(router, 'navigate');
spyOn(imgSvc, 'delete').and.returnValue(of(undefined));
component.confirmDelete();
expect(imgSvc.delete).toHaveBeenCalledWith('img-1');
expect(imgSvc.delete).toHaveBeenCalledWith('AbCd1234');
expect(router.navigate).toHaveBeenCalledWith(['/']);
});
@@ -111,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();
@@ -143,4 +143,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');
});
});

View File

@@ -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">&#x1F517;</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 {
@@ -203,7 +211,7 @@ export class DetailComponent implements OnInit {
removeTag(tag: string): void {
if (!this.image) return;
const updated = this.image.tags.filter((t) => t !== tag);
this.imageService.updateTags(this.image.id, updated).subscribe({
this.imageService.updateTags(this.image.short_id, updated).subscribe({
next: (img) => { this.image = img; this.tagError = ''; this.cdr.markForCheck(); },
error: (err) => { this.tagError = err?.error?.detail ?? 'Failed to remove tag'; this.cdr.markForCheck(); },
});
@@ -213,7 +221,7 @@ export class DetailComponent implements OnInit {
if (!this.image || !tag.trim()) return;
const normalised = tag.trim().toLowerCase();
const updated = [...this.image.tags, normalised];
this.imageService.updateTags(this.image.id, updated).subscribe({
this.imageService.updateTags(this.image.short_id, updated).subscribe({
next: (img) => { this.image = img; this.newTagInput = ''; this.tagError = ''; this.cdr.markForCheck(); },
error: (err) => { this.tagError = err?.error?.detail ?? 'Invalid tag'; this.cdr.markForCheck(); },
});
@@ -224,7 +232,7 @@ export class DetailComponent implements OnInit {
confirmDelete(): void {
if (!this.image) return;
this.imageService.delete(this.image.id).subscribe({
this.imageService.delete(this.image.short_id).subscribe({
next: () => this.router.navigate(['/']),
error: () => { this.showDeleteDialog = false; this.cdr.markForCheck(); },
});
@@ -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 {

View File

@@ -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,
items: [{ id: '1', short_id: 'ShrtImg1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: 'ShrtImg1', thumbnail_key: null, file_url: '/api/v1/i/ShrtImg1/file', thumbnail_url: null, created_at: '' }],
total: 1, limit: 24, offset: 0,
};
const MULTI_PAGE = {
items: Array(24).fill(null).map((_, i) => ({
id: String(i + 1), short_id: `Shrt${String(i + 1).padStart(4, '0')}`, filename: `img${i + 1}.jpg`, tags: [], hash: '',
mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1,
storage_key: `Shrt${String(i + 1).padStart(4, '0')}`, thumbnail_key: null,
file_url: `/api/v1/i/Shrt${String(i + 1).padStart(4, '0')}/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,300 @@ describe('LibraryComponent', () => {
const link = (fixture.nativeElement as HTMLElement).querySelector('a[href="/tags"]');
expect(link).not.toBeNull();
});
// ---- Pagination: page window (T002) ----
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 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', () => {
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('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: 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' }) });
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();
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',
}));
});
it('clicking an image card navigates to /i/:short_id', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(ONE_IMAGE));
fixture.detectChanges();
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
const card = (fixture.nativeElement as HTMLElement).querySelector('.image-card') as HTMLElement;
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

@@ -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>
@@ -70,8 +70,8 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
class="image-card"
role="button"
tabindex="0"
(click)="router.navigate(['/images', img.id])"
(keydown.enter)="router.navigate(['/images', img.id])"
(click)="router.navigate(['/i', img.short_id])"
(keydown.enter)="router.navigate(['/i', img.short_id])"
>
<img
[src]="img.thumbnail_url ?? img.file_url"
@@ -85,7 +85,23 @@ 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 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>
`,
styles: [`
@@ -110,7 +126,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 +137,12 @@ 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: 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 {
@@ -129,10 +152,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 +172,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 +207,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 +234,53 @@ 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++;
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 +303,12 @@ export class LibraryComponent implements OnInit {
applyFilter(tags: string[]): void {
this.activeFilters = tags;
this.offset = 0;
this.currentPage = 1;
this.images = [];
this.load();
}
loadMore(): void {
this.router.navigate([], {
queryParams: { page: 1, tags: tags.length ? tags.join(',') : null },
queryParamsHandling: 'merge',
});
this.load();
}

View File

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

@@ -4,6 +4,7 @@ import { Observable } from 'rxjs';
export interface ImageRecord {
id: string;
short_id: string;
hash: string;
filename: string;
mime_type: string;
@@ -50,14 +51,14 @@ export class ImageService {
}
get(id: string): Observable<ImageRecord> {
return this.http.get<ImageRecord>(`${this.base}/images/${id}`);
return this.http.get<ImageRecord>(`${this.base}/i/${id}`);
}
updateTags(id: string, tags: string[]): Observable<ImageRecord> {
return this.http.patch<ImageRecord>(`${this.base}/images/${id}/tags`, { tags });
return this.http.patch<ImageRecord>(`${this.base}/i/${id}/tags`, { tags });
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.base}/images/${id}`);
return this.http.delete<void>(`${this.base}/i/${id}`);
}
}

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

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

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

View 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) {}
}

View File

@@ -37,9 +37,9 @@ describe('UploadComponent', () => {
component = fixture.componentInstance;
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
await component.handleUploadResponse({ id: 'abc', duplicate: true } as Parameters<typeof component.handleUploadResponse>[0]);
await component.handleUploadResponse({ id: 'abc', short_id: 'AbCd1234', duplicate: true } as Parameters<typeof component.handleUploadResponse>[0]);
expect(component.toastMessage).toContain('library');
expect(router.navigate).toHaveBeenCalledWith(['/images', 'abc']);
expect(router.navigate).toHaveBeenCalledWith(['/i', 'AbCd1234']);
});
it('on success response: shows success toast and navigates to detail', async () => {
@@ -47,9 +47,9 @@ describe('UploadComponent', () => {
component = fixture.componentInstance;
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
await component.handleUploadResponse({ id: 'xyz', duplicate: false } as Parameters<typeof component.handleUploadResponse>[0]);
expect(component.toastMessage).toBeTruthy();
expect(router.navigate).toHaveBeenCalledWith(['/images', 'xyz']);
await component.handleUploadResponse({ id: 'xyz', short_id: 'XyZ12345', duplicate: false } as Parameters<typeof component.handleUploadResponse>[0]);
expect(component.showSuccess).toBeTrue();
expect(router.navigate).toHaveBeenCalledWith(['/i', 'XyZ12345']);
});
it('on error response: shows inline error, no navigation', async () => {

View File

@@ -180,7 +180,7 @@ export class UploadComponent {
this.cdr.markForCheck();
}, 4000);
}
await this.router.navigate(['/images', res.id]);
await this.router.navigate(['/i', res.short_id]);
}
handleUploadError(err: unknown): void {
@@ -192,6 +192,7 @@ export class UploadComponent {
} else {
this.errorMessage = 'Upload failed. Please try again.';
}
this.cdr.markForCheck();
}
resetForm(): void {

View File

@@ -5,6 +5,17 @@
<title>Reactbin</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="Reactbin">
<meta property="og:description" content="Find your perfect reaction image.">
<meta property="og:url" content="https://reactbin.juggalol.com">
<meta property="og:image" content="https://cdn.reactbin.juggalol.com/0bdaf046f534a1c913629d7ce8069ce407898c19794527bf842524ff4c0073de">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Reactbin">
<meta name="twitter:description" content="Find your perfect reaction image.">
<meta name="twitter:image" content="https://cdn.reactbin.juggalol.com/0bdaf046f534a1c913629d7ce8069ce407898c19794527bf842524ff4c0073de">
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">