Compare commits
2 Commits
004-jwt-be
...
005-ui-pol
| Author | SHA1 | Date | |
|---|---|---|---|
| 9246f75fdd | |||
| 5179786261 |
@@ -1,3 +1 @@
|
||||
{
|
||||
"feature_directory": "specs/004-jwt-bearer-auth"
|
||||
}
|
||||
{"feature_directory": "specs/005-ui-polish"}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!--
|
||||
SYNC IMPACT REPORT
|
||||
==================
|
||||
Version change: 1.1.0 → 1.1.1
|
||||
Version change: 1.1.1 → 1.2.0
|
||||
Ratified: 2026-05-01 | Last amended: 2026-05-03
|
||||
|
||||
Principles introduced (first population from docs/CONSTITUTION.md):
|
||||
@@ -82,14 +82,14 @@ or SDK-specific types directly — only the interface contract.
|
||||
|
||||
### 2.4 Auth abstraction (progressive)
|
||||
|
||||
Authentication is treated as a pluggable backend from day one, even though
|
||||
Phase 1 ships with no auth. The API MUST route all request-identity resolution
|
||||
through a single `AuthProvider` interface. The no-op provider (Phase 1) returns
|
||||
a static anonymous identity. Adding username/password or OIDC in a later phase
|
||||
MUST be a new provider implementation, not a rewrite of business logic.
|
||||
Authentication is treated as a pluggable backend from day one. The API MUST
|
||||
route all request-identity resolution through a single `AuthProvider` interface.
|
||||
Each phase introduces a new provider implementation; no phase rewrites business
|
||||
logic already behind the interface.
|
||||
|
||||
**Phase 1 implements: no-auth (localhost only).**
|
||||
**Planned phases: username/password, then OIDC.**
|
||||
**Phase 1 — no-auth (NoOpAuthProvider): complete.**
|
||||
**Phase 2 — JWT bearer token (JWTAuthProvider, HS256, single owner): complete.**
|
||||
**Phase 3 — OIDC: planned.**
|
||||
The constitution acknowledges all three; the spec governs which is built.
|
||||
|
||||
### 2.5 Database abstraction
|
||||
@@ -206,6 +206,7 @@ NOT be marked complete while CI is failing.
|
||||
| ORM / query | SQLAlchemy 2.x (async) + asyncpg driver | Repository layer owns all queries |
|
||||
| DB migrations | Alembic | Schema changes tracked in version control |
|
||||
| Object storage | S3-compatible via `boto3` / `aiobotocore` | Swap MinIO ↔ S3 via env config |
|
||||
| Auth tokens | PyJWT (HS256) | Lightweight; compatible with OIDC migration path |
|
||||
| UI framework | Angular (latest stable) | Job-relevant, learning goal |
|
||||
| UI language | TypeScript strict mode | No `any`, no implicit types |
|
||||
| Containerisation | Docker + Docker Compose | Local dev must start with one command |
|
||||
@@ -244,7 +245,6 @@ revised:
|
||||
- Image editing or transformation beyond thumbnail generation
|
||||
- OR/NOT tag logic
|
||||
- Mobile-native app
|
||||
- Username/password auth (planned Phase 2)
|
||||
- OIDC auth (planned Phase 3)
|
||||
|
||||
---
|
||||
@@ -283,7 +283,8 @@ Phase 1 design is complete.
|
||||
| 1.1.0 | 2026-05-01 | asyncpg driver explicit; SHA-256 deduplication added to data model; deduplication removed from out-of-scope |
|
||||
| 1.1.0 | 2026-05-02 | Adopted into Spec Kit memory; fixed duplicate §4.3 → §4.4; strengthened "should" language to MUST/MUST NOT; added §9 Governance |
|
||||
| 1.1.1 | 2026-05-03 | Clarify that the only acceptable form of image transformation or editing is thumbnail generation |
|
||||
| 1.2.0 | 2026-05-03 | §2.4: Mark Phase 2 (JWT bearer auth) complete, reword phase status; §6: Add PyJWT to tech stack table; §8: Remove username/password auth from out-of-scope (now shipped) |
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.1.1 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-03
|
||||
**Version**: 1.2.0 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-03
|
||||
|
||||
@@ -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/004-jwt-bearer-auth/plan.md`.
|
||||
`specs/005-ui-polish/plan.md`.
|
||||
<!-- SPECKIT END -->
|
||||
|
||||
34
specs/005-ui-polish/checklists/requirements.md
Normal file
34
specs/005-ui-polish/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: UI Polish & Design System
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-05-03
|
||||
**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. Spec is ready for `/speckit-plan`.
|
||||
242
specs/005-ui-polish/plan.md
Normal file
242
specs/005-ui-polish/plan.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# Implementation Plan: UI Polish & Design System
|
||||
|
||||
**Branch**: `005-ui-polish` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/005-ui-polish/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Refine the existing Angular SPA from functional-but-bare to intentional and
|
||||
finished. All changes are purely front-end: a shared design-token layer
|
||||
(CSS custom properties) is introduced in `styles.css`, and each of the five
|
||||
views (library, upload, detail, login, app shell) is updated to use those tokens
|
||||
and to handle loading, empty, and error states consistently. No new dependencies,
|
||||
no new API endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5 / Angular 19 (standalone components, no NgModules)
|
||||
**Primary Dependencies**: Angular 19, RxJS 7 (already installed; no new deps added)
|
||||
**Storage**: N/A — UI-only feature
|
||||
**Testing**: Karma / Jasmine (Angular CLI default; `npm test`)
|
||||
**Target Platform**: Browser SPA (desktop-primary, 375 px minimum viewport)
|
||||
**Project Type**: Web application — UI layer only
|
||||
**Performance Goals**: Loading indicators must not flash on sub-150 ms responses
|
||||
**Constraints**: No new npm dependencies; no external icon or component library
|
||||
**Scale/Scope**: Five component files + one global CSS file
|
||||
|
||||
---
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| §2.1 Strict separation of concerns — UI knows nothing about storage or DB | ✅ Pass | No API or storage changes |
|
||||
| §2.2 Dependency direction — UI → API only | ✅ Pass | No new API calls introduced |
|
||||
| §2.3 Storage abstraction | ✅ Pass | Not touched |
|
||||
| §2.4 Auth abstraction — identity resolution via AuthProvider | ✅ Pass | Auth logic unchanged; FR-006 (hide write controls) already implemented |
|
||||
| §2.6 No speculative abstraction | ✅ Pass | Tokens centralised because all five views use them; no hypothetical interfaces |
|
||||
| §3.3 Error shape | ✅ Pass | UI consumes existing error envelopes; no API change |
|
||||
| §5.1 TDD non-negotiable | ✅ Pass | All template and state changes will have Angular component tests written first |
|
||||
| §5.2 Test pyramid | ✅ Pass | Unit tests (Karma) cover state logic; E2E visual check is the acceptance gate |
|
||||
| §6 Tech stack | ✅ Pass | Angular + TypeScript; no new languages or frameworks added |
|
||||
| §7.2 No hardcoded values | ✅ Pass | Colours/spacing moved to CSS custom properties, not hardcoded further |
|
||||
| §7.3 Linting non-optional | ✅ Pass | ESLint + Prettier enforced; `ng build` type-check must pass |
|
||||
| §8 Scope boundaries | ✅ Pass | UI-only; no multi-user, no OR/NOT tags, no OIDC |
|
||||
|
||||
**Constitution Check result: ALL GATES PASS**
|
||||
|
||||
No violations. No complexity justification table required.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/005-ui-polish/
|
||||
├── plan.md ← this file
|
||||
├── research.md ← Phase 0 output (complete)
|
||||
├── quickstart.md ← Phase 1 output (visual acceptance scenarios)
|
||||
└── tasks.md ← Phase 2 output (/speckit-tasks — not yet created)
|
||||
```
|
||||
|
||||
*No `data-model.md` or `contracts/` — this feature introduces no new data
|
||||
entities and no API surface changes.*
|
||||
|
||||
### Source Code (affected files)
|
||||
|
||||
```text
|
||||
ui/
|
||||
└── src/
|
||||
├── styles.css ← Add CSS custom properties (design tokens)
|
||||
└── app/
|
||||
├── app.component.ts ← Polish header shell
|
||||
├── library/
|
||||
│ └── library.component.ts ← Skeleton load, empty state, error state, card polish
|
||||
├── upload/
|
||||
│ └── upload.component.ts ← Drop-zone polish, in-progress state, success/error states
|
||||
├── detail/
|
||||
│ └── detail.component.ts ← Loading state, not-found state, section organisation
|
||||
└── login/
|
||||
└── login.component.ts ← Visual alignment with design system
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Milestones
|
||||
|
||||
### Milestone 1 — Design Token Layer (blocks all other milestones)
|
||||
|
||||
Extract the shared colour, spacing, and motion values already present across the
|
||||
five components into CSS custom properties on `:root` in `styles.css`.
|
||||
|
||||
**Deliverable**: `:root` block in `styles.css` with 13 named tokens (see
|
||||
research.md Decision 5). Each existing component still renders identically
|
||||
(tokens match current hard-coded values exactly). `ng build` passes.
|
||||
|
||||
**Token set**:
|
||||
```
|
||||
--bg, --surface, --surface-raised, --border, --border-focus,
|
||||
--text, --text-muted, --accent, --accent-text, --danger, --danger-text,
|
||||
--radius, --radius-chip, --transition
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Milestone 2 — Library View (US1)
|
||||
|
||||
**Loading state**: Replace the current `loading = true` boolean with the
|
||||
150 ms–debounced spinner pattern (see research.md Decision 3). While loading,
|
||||
render a grid of skeleton cards (same dimensions as real cards) using the
|
||||
shimmer CSS class (see research.md Decision 2).
|
||||
|
||||
**Empty state**: The existing empty-state `<p>` is already functional. Polish it:
|
||||
centred layout, muted icon (✦ or similar Unicode), larger text, and a prominent
|
||||
"Upload your first image" link that navigates to `/upload`.
|
||||
|
||||
**Error state**: Add an `error: boolean` flag to the component. If the `list()`
|
||||
call errors, set `error = true` and render an error card with a retry button
|
||||
that calls `load()` again.
|
||||
|
||||
**Card polish**: Apply tokens to card background, border-radius, and tag chips.
|
||||
Add a subtle `box-shadow` and `transform: translateY(-2px)` on hover (using
|
||||
`--transition`). Ensure the card thumbnail `<img>` has an `(error)` fallback
|
||||
(see research.md Decision 4).
|
||||
|
||||
**Responsive**: The existing `auto-fill minmax(200px, 1fr)` grid already handles
|
||||
narrow viewports. Verify it does not overflow at 375 px; reduce min card width
|
||||
to 160 px if needed.
|
||||
|
||||
---
|
||||
|
||||
### Milestone 3 — Upload View (US2)
|
||||
|
||||
**Drop-zone polish**: Apply token-based border and background to the existing
|
||||
drag-and-drop zone. Add a dashed border accent colour (`--accent` at 40%
|
||||
opacity) on active drag state.
|
||||
|
||||
**In-progress state**: The existing `loading` flag already disables the button.
|
||||
Add a visible spinner or animated label ("Uploading…") inside the button while
|
||||
in-flight so the state change is unmistakable.
|
||||
|
||||
**Success state**: After a successful upload, show a brief success banner
|
||||
(green-tinted surface, tick character) with a "Upload another" link and a "View
|
||||
in library" link. Auto-dismiss after 4 seconds or on navigation.
|
||||
|
||||
**Error states**: Distinct messages for validation errors (wrong type/size —
|
||||
already returned by API) vs. network/server errors (generic retry). Both
|
||||
displayed inline below the form, not in a modal.
|
||||
|
||||
**Double-submit prevention**: Already implemented (button disabled while
|
||||
`loading`). Confirm the disabled style is visually clear using `--text-muted`
|
||||
and reduced opacity.
|
||||
|
||||
---
|
||||
|
||||
### Milestone 4 — Detail View (US3)
|
||||
|
||||
**Loading state**: Add a skeleton layout while `loading = true`: a grey
|
||||
rectangle at full width for the image area, and two skeleton chip rows below.
|
||||
|
||||
**Not-found state**: The existing `!image && !loading` condition renders a
|
||||
plain text paragraph. Replace with a styled not-found card: centred layout,
|
||||
muted icon, "Image not found" heading, and a "Back to library" button.
|
||||
|
||||
**Section organisation**: Visually separate the image area, tags section, and
|
||||
write controls with consistent spacing using `--surface` panels and token-based
|
||||
gaps. Write controls (tag input + delete button) should be grouped in a visually
|
||||
distinct "Owner actions" area when visible.
|
||||
|
||||
**Tag error**: The existing `tagError` renders inline. Apply `--danger` colour
|
||||
and a left border accent to make it unmistakable.
|
||||
|
||||
**Broken image**: Add `(error)` handler on the full-size `<img>` in the detail
|
||||
view (inline SVG placeholder showing a broken-link icon).
|
||||
|
||||
---
|
||||
|
||||
### Milestone 5 — Login View (US4)
|
||||
|
||||
Apply the token-based design system to the login form:
|
||||
- Centre the card vertically and horizontally on the page
|
||||
- Wrap the form in a `--surface` card with `--radius` and a subtle border
|
||||
- Use token-based input styles matching the library filter bar
|
||||
- Display field-level validation errors using `--danger` colour
|
||||
- The submit button uses the same `--accent` style as the library upload button
|
||||
- In-progress state: button text changes to "Signing in…", button disabled
|
||||
|
||||
No layout changes to the existing reactive-form structure.
|
||||
|
||||
---
|
||||
|
||||
### Milestone 6 — App Shell (US5)
|
||||
|
||||
The existing `app.component.ts` header already conditionally renders the
|
||||
sign-out button. Polish:
|
||||
- Slim top bar: `--surface` background, bottom border using `--border`
|
||||
- App name / logo mark on the left (text only, no image asset)
|
||||
- Sign-out button aligned right using `--text-muted` colour and a simple
|
||||
hover state
|
||||
- Header height: `48px` fixed; does not reflow page content on state change
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```
|
||||
Milestone 1 (tokens)
|
||||
↓
|
||||
Milestone 2 (library) ─┐
|
||||
Milestone 3 (upload) ├─ can proceed in parallel after M1
|
||||
Milestone 4 (detail) │
|
||||
Milestone 5 (login) ─┘
|
||||
Milestone 6 (shell) ← last (touches app.component which wraps all views)
|
||||
```
|
||||
|
||||
M2–M5 are independent of each other (different component files). M6 is last
|
||||
because the app shell wraps all views and its final state is easiest to validate
|
||||
once the inner views are stable.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
**Unit tests (Karma/Jasmine)**:
|
||||
- All new state variables (`error`, `showSpinner`, skeleton visibility) are
|
||||
tested via component spec files.
|
||||
- Template conditionals (`*ngIf="error"`, `*ngIf="loading"`) are verified with
|
||||
fixture queries.
|
||||
- The `(error)` image fallback handler is tested by simulating an error event.
|
||||
- Existing tests must continue to pass — no regressions.
|
||||
|
||||
**Visual acceptance (manual, quickstart.md)**:
|
||||
- Each milestone has a corresponding scenario in quickstart.md.
|
||||
- Visual checks are performed in a running `docker compose up` stack.
|
||||
- 375 px viewport check: Chrome DevTools → device toolbar → iPhone SE.
|
||||
|
||||
**Build gate**: `ng build` must pass with zero errors after every milestone.
|
||||
155
specs/005-ui-polish/quickstart.md
Normal file
155
specs/005-ui-polish/quickstart.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Quickstart: UI Polish Visual Acceptance Scenarios
|
||||
|
||||
Use this guide to manually verify each milestone after implementation.
|
||||
Run `docker compose up` before starting. Open the browser at `http://localhost:4200`.
|
||||
|
||||
---
|
||||
|
||||
## M1 — Design Token Layer
|
||||
|
||||
**Goal**: Tokens exist; visual output is identical to before.
|
||||
|
||||
1. Open the library, upload, detail, and login pages.
|
||||
2. Open browser DevTools → Elements → `<html>` or `<body>`.
|
||||
3. Confirm `--bg`, `--surface`, `--accent`, `--danger` etc. are visible in
|
||||
computed styles.
|
||||
4. Confirm no visible change in any view compared to before M1.
|
||||
|
||||
---
|
||||
|
||||
## M2 — Library View
|
||||
|
||||
### Loading skeleton
|
||||
1. Open DevTools → Network → set throttle to "Slow 3G".
|
||||
2. Hard-refresh the library page.
|
||||
3. **Expect**: A grid of grey shimmer cards appears immediately; no blank white
|
||||
space; no layout jump when real images load in.
|
||||
|
||||
### Empty state
|
||||
1. Ensure no images are uploaded (or use a fresh test database).
|
||||
2. Open the library.
|
||||
3. **Expect**: A centred empty-state panel with explanatory text and a prominent
|
||||
"Upload your first image" link. Clicking the link navigates to `/upload`.
|
||||
|
||||
### Error state
|
||||
1. Stop the API container (`docker compose stop api`).
|
||||
2. Hard-refresh the library.
|
||||
3. **Expect**: An error card with a plain-language message and a "Retry" button.
|
||||
No blank grid, no raw status code.
|
||||
4. Restart the API (`docker compose start api`) and click "Retry".
|
||||
5. **Expect**: Images load successfully.
|
||||
|
||||
### Card polish
|
||||
1. Hover over an image card.
|
||||
2. **Expect**: Card lifts slightly (2 px translate) with a smooth transition.
|
||||
|
||||
### Broken image
|
||||
1. Manually corrupt a storage key in the database (or unplug MinIO) and reload.
|
||||
2. **Expect**: Card shows a grey placeholder graphic, not a broken-image browser icon.
|
||||
|
||||
### 375 px viewport
|
||||
1. DevTools → Device toolbar → iPhone SE (375 × 667).
|
||||
2. **Expect**: Cards stack, no horizontal scrollbar, all content readable.
|
||||
|
||||
---
|
||||
|
||||
## M3 — Upload View
|
||||
|
||||
### Drop-zone idle
|
||||
1. Navigate to `/upload` (must be logged in).
|
||||
2. **Expect**: A visually distinct drop-zone with dashed border and clear
|
||||
instructions. Submit button is disabled/greyed.
|
||||
|
||||
### In-progress
|
||||
1. Select a large image file.
|
||||
2. Click upload.
|
||||
3. **Expect**: Button label changes to "Uploading…" and is disabled. A spinner
|
||||
or animated indicator is visible.
|
||||
|
||||
### Success
|
||||
1. After a successful upload completes.
|
||||
2. **Expect**: A green-tinted success banner with the filename, "Upload another"
|
||||
link, and "View in library" link. Banner disappears after 4 seconds.
|
||||
|
||||
### Validation error
|
||||
1. Attempt to upload a `.txt` file.
|
||||
2. **Expect**: An inline error message names the problem ("Unsupported file type").
|
||||
The form is still usable — no page reload required.
|
||||
|
||||
### Network error
|
||||
1. Stop the API mid-upload (or use DevTools → block the upload request).
|
||||
2. **Expect**: A generic inline error with guidance to retry. Form remains usable.
|
||||
|
||||
---
|
||||
|
||||
## M4 — Detail View
|
||||
|
||||
### Loading skeleton
|
||||
1. Set network throttle to Slow 3G.
|
||||
2. Navigate directly to an image URL (e.g., `http://localhost:4200/images/<id>`).
|
||||
3. **Expect**: A grey rectangle skeleton for the image area and chip skeletons
|
||||
below. No blank page.
|
||||
|
||||
### Not-found state
|
||||
1. Navigate to `http://localhost:4200/images/00000000-0000-0000-0000-000000000000`.
|
||||
2. **Expect**: A styled not-found card with "Image not found" heading and a
|
||||
"Back to library" button. No blank page, no raw 404 text.
|
||||
|
||||
### Authenticated write controls
|
||||
1. Log in and open a detail page.
|
||||
2. **Expect**: Tag editing input and delete button are visible and clearly grouped.
|
||||
|
||||
### Unauthenticated view
|
||||
1. Open the detail page in a private/incognito window (not logged in).
|
||||
2. **Expect**: Image and read-only tag chips are visible. No tag input, no
|
||||
delete button.
|
||||
|
||||
### Tag error
|
||||
1. While logged in, attempt to add a tag with invalid characters (e.g., `TAG!`).
|
||||
2. **Expect**: An inline error in danger colour with a left accent border.
|
||||
Other tags and the image remain visible.
|
||||
|
||||
### Broken image
|
||||
1. Open a detail page for an image whose storage object has been deleted.
|
||||
2. **Expect**: A placeholder graphic replaces the image. Page layout is not broken.
|
||||
|
||||
---
|
||||
|
||||
## M5 — Login View
|
||||
|
||||
### Visual consistency
|
||||
1. Open `/login` without being logged in.
|
||||
2. **Expect**: Dark background matching the library; form centred in a surface
|
||||
card; same font and spacing as other pages.
|
||||
|
||||
### Field validation
|
||||
1. Click the Sign In button without entering any credentials.
|
||||
2. **Expect**: Inline validation messages appear on the username and password
|
||||
fields without a page reload.
|
||||
|
||||
### Invalid credentials error
|
||||
1. Enter wrong credentials and submit.
|
||||
2. **Expect**: A single error message below the form. Fields retain their values.
|
||||
|
||||
### In-progress state
|
||||
1. Submit valid credentials (throttle network if needed to see the state).
|
||||
2. **Expect**: Button label changes to "Signing in…" and is disabled while the
|
||||
request is in flight.
|
||||
|
||||
---
|
||||
|
||||
## M6 — App Shell
|
||||
|
||||
### Authenticated header
|
||||
1. Log in and navigate between library, upload, and an image detail page.
|
||||
2. **Expect**: A consistent 48 px header is present on all pages. Sign-out
|
||||
control is visible on the right. Header does not reflow content.
|
||||
|
||||
### Unauthenticated header
|
||||
1. Open the library or detail page without logging in.
|
||||
2. **Expect**: Header is present but sign-out control is absent.
|
||||
|
||||
### Sign out
|
||||
1. Click the sign-out control in the header.
|
||||
2. **Expect**: Redirected to `/login`. Header no longer shows sign-out control.
|
||||
Navigating to `/upload` redirects back to `/login`.
|
||||
137
specs/005-ui-polish/research.md
Normal file
137
specs/005-ui-polish/research.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Research: UI Polish & Design System
|
||||
|
||||
## Decision 1: Design token delivery mechanism
|
||||
|
||||
**Decision**: CSS custom properties declared on `:root` in `styles.css`.
|
||||
|
||||
**Rationale**: Angular's default ViewEncapsulation.Emulated scopes component
|
||||
selectors with attribute hashes but does not block CSS custom property
|
||||
inheritance. `:root` variables cascade into every inline component style block
|
||||
without any special configuration. This is now the standard Angular 2025+ token
|
||||
approach and requires zero new dependencies.
|
||||
|
||||
**Alternatives considered**:
|
||||
- SCSS variables — require a preprocessor; project currently uses plain CSS.
|
||||
- A shared `.css` import per component — works but adds per-component boilerplate
|
||||
and duplicates the token surface unnecessarily.
|
||||
|
||||
---
|
||||
|
||||
## Decision 2: Skeleton loading pattern
|
||||
|
||||
**Decision**: Pure-CSS shimmer using `::after` pseudo-element animated gradient;
|
||||
no third-party library.
|
||||
|
||||
**Rationale**: The spec assumption explicitly prohibits new icon/component
|
||||
libraries. The standard pure-CSS skeleton pattern (placeholder divs with a
|
||||
light-to-dark horizontal gradient sweeping via `@keyframes`) produces the same
|
||||
visual result as library skeletons with zero added dependencies. The `::after`
|
||||
approach requires no extra DOM nodes per skeleton block.
|
||||
|
||||
**Pattern**:
|
||||
```css
|
||||
@keyframes shimmer {
|
||||
from { background-position: -200% 0; }
|
||||
to { background-position: 200% 0; }
|
||||
}
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-raised) 50%, var(--surface) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
```
|
||||
|
||||
**Alternatives considered**:
|
||||
- `@angular/material` skeleton — adds the entire Material library as a dep.
|
||||
- CSS opacity pulse — simpler but less visually informative than a shimmer.
|
||||
|
||||
---
|
||||
|
||||
## Decision 3: Loading-flash debounce
|
||||
|
||||
**Decision**: `timer(150).pipe(takeUntil(response$))` to gate the visibility of
|
||||
loading indicators.
|
||||
|
||||
**Rationale**: Showing a spinner immediately causes a visible flash when the
|
||||
server responds in under ~150 ms (common on localhost). The idiomatic RxJS
|
||||
approach is to start a 150 ms timer alongside the real request; if the request
|
||||
completes first (`takeUntil`), the timer never fires and the spinner never
|
||||
appears. This avoids `race()` complexity and cleanly unsubscribes.
|
||||
|
||||
**Implementation sketch**:
|
||||
```typescript
|
||||
showSpinner = false;
|
||||
load(): void {
|
||||
const req$ = this.service.fetch().pipe(share());
|
||||
timer(150).pipe(takeUntil(req$)).subscribe(() => { this.showSpinner = true; });
|
||||
req$.subscribe(data => { this.showSpinner = false; /* handle data */ });
|
||||
}
|
||||
```
|
||||
|
||||
**Alternatives considered**:
|
||||
- `race([req$, timer(150)])` — fires timer regardless of req$ speed on certain
|
||||
race conditions; harder to reason about.
|
||||
- CSS `animation-delay` — cannot easily tie delay to actual response time.
|
||||
|
||||
---
|
||||
|
||||
## Decision 4: Broken-image fallback
|
||||
|
||||
**Decision**: Inline `(error)` event binding on `<img>` elements, guarded
|
||||
against recursive fallback.
|
||||
|
||||
**Rationale**: For a small number of distinct image elements (card thumbnail,
|
||||
detail full-image), an event binding is the minimal idiomatic pattern and
|
||||
avoids the complexity of a directive. Recursive fallback is prevented by
|
||||
checking that the current `src` is not already the placeholder before
|
||||
reassigning.
|
||||
|
||||
**Pattern**:
|
||||
```html
|
||||
<img [src]="url" (error)="onImgError($event)" />
|
||||
```
|
||||
```typescript
|
||||
onImgError(e: Event): void {
|
||||
const img = e.target as HTMLImageElement;
|
||||
if (!img.src.endsWith('placeholder')) {
|
||||
img.src = 'data:image/svg+xml,...'; // inline SVG placeholder
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Alternatives considered**:
|
||||
- Custom `ImageFallbackDirective` — reusable but over-engineered for two call
|
||||
sites; can be extracted later if the pattern spreads.
|
||||
|
||||
---
|
||||
|
||||
## Decision 5: Global design token set
|
||||
|
||||
**Decision**: Seven semantic tokens defined on `:root` in `styles.css`, derived
|
||||
from colours already present in the components.
|
||||
|
||||
| Token | Value | Meaning |
|
||||
|--------------------|-----------|----------------------------------------------|
|
||||
| `--bg` | `#0f0f0f` | Page background (already in body) |
|
||||
| `--surface` | `#1a1a1a` | Card / input / panel background |
|
||||
| `--surface-raised` | `#252525` | Hover state, skeleton highlight |
|
||||
| `--border` | `#333` | Subtle dividers, input borders |
|
||||
| `--border-focus` | `#555` | Input focus ring |
|
||||
| `--text` | `#e0e0e0` | Primary text (already on body) |
|
||||
| `--text-muted` | `#777` | Secondary text, placeholders |
|
||||
| `--accent` | `#4a9eff` | CTAs, active chips (already in upload-btn) |
|
||||
| `--accent-text` | `#000` | Text on accent backgrounds |
|
||||
| `--danger` | `#c0392b` | Destructive actions (already in delete-btn) |
|
||||
| `--danger-text` | `#fff` | Text on danger backgrounds |
|
||||
| `--radius` | `6px` | Standard border radius |
|
||||
| `--radius-chip` | `12px` | Pill-shaped chips |
|
||||
| `--transition` | `200ms ease` | Standard animation duration |
|
||||
|
||||
**Rationale**: All values are already present in the components but hard-coded
|
||||
per file. Centralising them eliminates drift without introducing a new colour
|
||||
palette — the visual result is identical to the current state, but consistent.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Introducing a new darker/lighter palette — unnecessary scope creep; the
|
||||
existing colours are well-chosen for a dark personal tool.
|
||||
180
specs/005-ui-polish/spec.md
Normal file
180
specs/005-ui-polish/spec.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Feature Specification: UI Polish & Design System
|
||||
|
||||
**Feature Branch**: `005-ui-polish`
|
||||
**Created**: 2026-05-03
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Polish the Angular UI with a cohesive visual design. The three main views — library (image grid), upload form, and image detail — should feel intentional and finished. Add proper loading states, empty states, and error states to each view. The overall aesthetic should be dark-themed and minimal, fitting a personal tool used frequently. The login page should also match the design system."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Library Feels Complete (Priority: P1)
|
||||
|
||||
The owner opens the app and is greeted by a polished image grid. While images
|
||||
load, something visually coherent fills the space so the page doesn't feel
|
||||
broken. If the library is empty, a helpful prompt explains what to do. If the
|
||||
request fails, a clear error message appears with a way to retry.
|
||||
|
||||
**Why this priority**: The library is the landing page and the most-visited
|
||||
view. Its quality sets first impressions for every session.
|
||||
|
||||
**Independent Test**: Open the app with no images uploaded — confirm an
|
||||
intentional empty state is shown. Upload an image, return to the library —
|
||||
confirm the grid renders cleanly with consistent card sizing. Throttle the
|
||||
network — confirm a loading indicator appears before images arrive.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the library is loading, **When** the page first renders, **Then** a skeleton or spinner occupies the grid area so the layout does not jump or appear blank.
|
||||
2. **Given** no images have been uploaded, **When** the library loads successfully, **Then** an empty-state message is shown explaining that no images exist yet, with a visible prompt to upload the first image.
|
||||
3. **Given** the image fetch fails (network error), **When** the library loads, **Then** an error message is shown with a retry action; the page does not display a blank grid or a raw error code.
|
||||
4. **Given** images exist, **When** the library renders, **Then** all image cards have consistent size, spacing, and visual weight; tag chips are readable and do not overflow their cards.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Upload Form Feels Finished (Priority: P1)
|
||||
|
||||
The owner navigates to the upload page and finds a form that clearly communicates
|
||||
its state at every step: idle with a helpful drop-zone, active while uploading
|
||||
with visible progress, and resolved with success or a plain-language error.
|
||||
|
||||
**Why this priority**: Upload is the primary write action. A rough upload
|
||||
experience erodes confidence in the whole tool.
|
||||
|
||||
**Independent Test**: Upload a valid image and confirm the flow from drop-zone
|
||||
through in-progress indicator to success result is smooth and clearly
|
||||
communicated. Attempt an upload with an invalid file type and confirm a
|
||||
plain-language validation error appears without a page reload.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the upload page is idle, **When** no file is selected, **Then** a drop-zone with clear instructions is visible; the submit button is visibly disabled.
|
||||
2. **Given** a file is selected and uploading, **When** the upload is in progress, **Then** the submit button is disabled and a visible in-progress indicator is shown; the user cannot accidentally submit twice.
|
||||
3. **Given** an upload succeeds, **When** the server responds, **Then** a success confirmation is shown and the owner can navigate onward without confusion.
|
||||
4. **Given** an upload fails due to an invalid file type or size, **When** the server responds, **Then** a plain-language error message is shown identifying the problem; the form remains usable for another attempt.
|
||||
5. **Given** an upload fails due to a network or server error, **When** the server responds, **Then** a generic error message is shown with guidance to retry.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Detail Page Is Well Organised (Priority: P1)
|
||||
|
||||
The owner opens an image's detail page and finds the image prominently displayed,
|
||||
tag management clearly grouped, and write controls (edit tags, delete) visually
|
||||
distinct from read content. Visitors who are not logged in see the image and
|
||||
tags but no write controls. Loading and error states are handled gracefully.
|
||||
|
||||
**Why this priority**: The detail page is where tag curation and deletion
|
||||
happen — the two most common editing actions after upload.
|
||||
|
||||
**Independent Test**: Open a detail page while logged in — confirm write
|
||||
controls are visible and clearly grouped. Open the same page while logged out —
|
||||
confirm write controls are hidden. Navigate to a non-existent image ID — confirm
|
||||
a not-found state is shown rather than a blank or broken page.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the detail page is loading, **When** the route is first entered, **Then** a loading indicator is shown in place of the image and metadata.
|
||||
2. **Given** the image exists and the owner is logged in, **When** the page renders, **Then** the image is the focal point; tags are displayed below; tag editing and delete controls are clearly grouped and visually differentiated from read content.
|
||||
3. **Given** the image exists and the visitor is not logged in, **When** the page renders, **Then** the image and tags are visible; no tag-edit input or delete button is present.
|
||||
4. **Given** a non-existent image ID is requested, **When** the page loads, **Then** a not-found state is shown with a link back to the library; no raw error code or blank area is displayed.
|
||||
5. **Given** a tag update fails, **When** the owner submits a tag change, **Then** an inline error message explains the failure; the image and other tags remain visible.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Login Page Matches the Design (Priority: P2)
|
||||
|
||||
The owner lands on the login page (directly or after a redirect) and finds a
|
||||
form that visually belongs to the same application as the library and detail
|
||||
page. The form clearly communicates validation errors and submission state.
|
||||
|
||||
**Why this priority**: Login is visited infrequently. A consistent visual
|
||||
treatment matters, but functional correctness (already implemented) is more
|
||||
critical than aesthetic alignment.
|
||||
|
||||
**Independent Test**: Navigate to `/login` directly — confirm the page uses the
|
||||
same colour scheme, typography, and spacing as the rest of the app. Submit with
|
||||
empty fields — confirm visible validation errors appear without a page reload.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the login page loads, **When** the owner views it, **Then** the page uses the same dark background, colour palette, and typographic scale as all other views.
|
||||
2. **Given** the owner submits with empty username or password, **When** the form is submitted, **Then** inline validation messages appear on the relevant fields without a page reload or server round-trip.
|
||||
3. **Given** the owner submits invalid credentials, **When** the server rejects them, **Then** a single error message is shown below the form; the fields are not cleared.
|
||||
4. **Given** the form is submitting, **When** the request is in-flight, **Then** the submit button is disabled and shows an in-progress label so the owner cannot submit twice.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 — App Shell Is Consistent (Priority: P2)
|
||||
|
||||
Every page shares a consistent outer frame: a slim header that shows the
|
||||
sign-out control when logged in. The header does not compete with page content
|
||||
for visual attention but is always present and usable.
|
||||
|
||||
**Why this priority**: A coherent shell ties the individual views together into
|
||||
a single application rather than a collection of pages.
|
||||
|
||||
**Independent Test**: Navigate between library, detail, and upload while logged
|
||||
in — confirm the header is consistent across all views. Sign out and visit a
|
||||
public page — confirm the sign-out control is absent.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the owner is logged in, **When** viewing any page, **Then** a slim header is present with a sign-out control; it does not draw excessive visual attention away from the page content.
|
||||
2. **Given** the visitor is not logged in, **When** viewing the library or a detail page, **Then** the header is present but contains no sign-out control.
|
||||
3. **Given** the owner clicks sign out in the header, **When** the action completes, **Then** they are redirected to the login page and the header no longer shows the sign-out control.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens if an image fails to load (broken URL or storage outage)? The card or detail view should show a placeholder, not the browser's default broken-image icon.
|
||||
- What happens on a very narrow viewport (mobile browser)? Cards should stack or resize; the layout must not overflow horizontally.
|
||||
- What happens if a tag is very long? Tag chips must truncate or wrap without breaking the card or detail layout.
|
||||
- What happens during slow network conditions? Loading states must appear promptly and not flash on fast connections.
|
||||
|
||||
---
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: Every view (library, upload, detail, login) MUST display a loading indicator while async data or actions are in progress.
|
||||
- **FR-002**: The library MUST display a meaningful empty-state message with a call to action when no images exist.
|
||||
- **FR-003**: All four views MUST display plain-language error messages when an operation fails; raw HTTP status codes or stack traces MUST NOT be shown to the user.
|
||||
- **FR-004**: The upload form MUST disable the submit control while an upload is in progress to prevent duplicate submissions.
|
||||
- **FR-005**: The detail page MUST show a not-found state (with a back link) when the requested image does not exist.
|
||||
- **FR-006**: Write controls on the detail page (tag editing, delete) MUST be hidden for unauthenticated visitors and visible only to the logged-in owner.
|
||||
- **FR-007**: All views MUST share a consistent set of visual tokens: background colours, text colours, spacing scale, border radii, and interactive-element styles.
|
||||
- **FR-008**: The application MUST be usable on viewports as narrow as 375 px (iPhone SE width) without horizontal overflow.
|
||||
- **FR-009**: Loading indicators MUST NOT flash on connections fast enough to resolve in under 150 ms; debounced or skeleton-based approaches are preferred.
|
||||
- **FR-010**: Broken image assets (failed loads) MUST display a visible placeholder rather than the browser's default broken-image icon.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Design token set**: The shared palette, spacing scale, and typographic rules that all views derive from (background, surface, border, text-primary, text-muted, accent, danger).
|
||||
- **Loading state**: A visual treatment applied to any view or element while data is being fetched or an action is in progress.
|
||||
- **Empty state**: A purposeful layout shown when a collection has zero items, including explanatory text and a next-action prompt.
|
||||
- **Error state**: A purposeful layout shown when an operation fails, including a plain-language description and (where applicable) a retry action.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Every view transitions from loading to content (or error/empty) without a layout shift visible to the naked eye.
|
||||
- **SC-002**: All five views pass a visual consistency check: an observer can identify them as belonging to the same application by colour, typography, and spacing alone.
|
||||
- **SC-003**: The library, upload, and detail views each render without horizontal scrollbars on a 375 px wide viewport.
|
||||
- **SC-004**: Each error condition (network failure, validation failure, not-found) produces a user-visible message within the current view — zero conditions result in a silent failure or blank screen.
|
||||
- **SC-005**: Loading indicators do not appear on responses that complete in under 150 ms in a local development environment (no flicker on fast connections).
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The existing dark colour palette already in the components (#1a1a1a backgrounds, #e0e0e0 text, #4a9eff accent) is the correct base; the polish work refines and extends it rather than replacing it wholesale.
|
||||
- No external component library or icon set is introduced; any icons needed are either inline SVG or Unicode characters to avoid new dependencies.
|
||||
- The app remains a single-page application; no server-side rendering or route-level transitions are in scope.
|
||||
- Mobile layout is "good enough to use" at 375 px rather than a fully optimised mobile-first redesign; a dedicated mobile redesign is out of scope.
|
||||
- No new API endpoints are needed; all changes are purely front-end.
|
||||
- Animations and transitions are minimal — a single standard duration applied consistently; no complex motion design.
|
||||
- FR-006 (hiding write controls for unauthenticated visitors) is already implemented in the detail component; this spec confirms the behaviour is preserved and visually correct, not that it needs to be built from scratch.
|
||||
242
specs/005-ui-polish/tasks.md
Normal file
242
specs/005-ui-polish/tasks.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# Tasks: UI Polish & Design System
|
||||
|
||||
**Input**: Design documents from `specs/005-ui-polish/`
|
||||
**Prerequisites**: plan.md ✓, spec.md ✓, research.md ✓, quickstart.md ✓
|
||||
|
||||
**Tests**: Component spec tests are included per §5.1 (TDD non-negotiable). Tests are written first and must fail before implementation begins. Karma/Jasmine via Angular CLI test runner.
|
||||
|
||||
**Organization**: Phase 2 (design token layer) blocks all user story phases. User story phases (Phase 3–7) are independent of each other and can proceed in parallel after Phase 2 completes.
|
||||
|
||||
**Milestone mapping** (cross-reference with `plan.md` and `quickstart.md`):
|
||||
Phase 2 = M1 (Tokens) | Phase 3 = M2 (Library) | Phase 4 = M3 (Upload) | Phase 5 = M4 (Detail) | Phase 6 = M5 (Login) | Phase 7 = M6 (Shell)
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
|
||||
- **[Story]**: Which user story this task belongs to (US1–US5)
|
||||
- All component files are under `ui/src/app/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Purpose**: Verify baseline state before any changes are made.
|
||||
|
||||
- [X] T001 Confirm `ng build` passes with zero errors in `ui/` (baseline gate before any changes)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational — M1: Design Token Layer
|
||||
|
||||
**Purpose**: Establish the shared CSS custom property layer in `ui/src/styles.css`. This is the blocking prerequisite for all five user story phases — no component work begins until these tokens exist.
|
||||
|
||||
**⚠️ CRITICAL**: No user story phase can begin until T004 passes.
|
||||
|
||||
- [X] T002 Add 13 CSS custom property tokens to `:root` in `ui/src/styles.css`: `--bg`, `--surface`, `--surface-raised`, `--border`, `--border-focus`, `--text`, `--text-muted`, `--accent`, `--accent-text`, `--danger`, `--danger-text`, `--radius`, `--radius-chip`, `--transition` — use exact values from research.md Decision 5
|
||||
- [X] T003 Add `@keyframes shimmer` animation and `.skeleton` utility class to `ui/src/styles.css` using the gradient pattern from research.md Decision 2
|
||||
- [X] T004 Confirm `ng build` passes with zero errors after token additions (M1 gate — components unchanged, visual output identical)
|
||||
|
||||
**Checkpoint**: Design token layer complete. User story phases 3–7 may now start.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Library Feels Complete (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: The library view has skeleton loading, a styled empty state, an error state with retry, polished cards with hover lift, image error fallback, and responsive layout at 375 px.
|
||||
|
||||
**Independent Test**: Throttle network to Slow 3G and hard-refresh `/` — confirm shimmer skeleton appears. Remove all images — confirm styled empty state with "Upload your first image" link. Stop API — confirm error card with Retry button. Hover a card — confirm 2 px lift. Set viewport to 375 px — confirm no horizontal scrollbar.
|
||||
|
||||
### Tests for User Story 1 (TDD — write first, confirm failure before T008) ⚠️
|
||||
|
||||
- [X] T005 [US1] Add component tests for `showSpinner` debounce flag, `error` flag, skeleton card count, empty-state link, error card retry button, and `onImgError` handler in `ui/src/app/library/library.component.spec.ts`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T006 [P] [US1] Replace all hardcoded colour and spacing values with CSS token variables in the component styles block of `ui/src/app/library/library.component.ts`
|
||||
- [X] T007 [US1] Replace `loading = true` boolean with `showSpinner = false` and add 150 ms debounce using `timer(150).pipe(takeUntil(req$))` from research.md Decision 3 in `ui/src/app/library/library.component.ts`
|
||||
- [X] T008 [US1] Add skeleton loading grid: while `showSpinner` is true render 8 `<div class="skeleton card-skeleton">` placeholders at the same dimensions as real cards in `ui/src/app/library/library.component.ts`
|
||||
- [X] T009 [US1] Add `error = false` flag; set it on `list()` failure; render an error card with plain-language message and "Retry" button that calls `load()` in `ui/src/app/library/library.component.ts`
|
||||
- [X] T010 [US1] Replace the plain empty-state `<p>` with a centred panel: Unicode icon (✦), larger muted heading, and a `routerLink="/upload"` "Upload your first image" link in `ui/src/app/library/library.component.ts`
|
||||
- [X] T011 [US1] Add card hover effect: `transform: translateY(-2px)` and `box-shadow` with `transition: var(--transition)` using `--surface-raised` in `ui/src/app/library/library.component.ts`
|
||||
- [X] T012 [US1] Add `(error)="onImgError($event)"` on the card thumbnail `<img>` and implement `onImgError` with an inline SVG placeholder (guard against recursive fallback per research.md Decision 4) in `ui/src/app/library/library.component.ts`
|
||||
- [X] T013 [US1] Check the `auto-fill minmax()` value in the grid at 375 px: if cards overflow horizontally, reduce min card width to `160px` and record the change; if no overflow, document "verified at 160px — no change needed" in a code comment in `ui/src/app/library/library.component.ts`
|
||||
|
||||
**Checkpoint**: Library view is fully functional with all loading/empty/error/responsive states.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Upload Form Feels Finished (Priority: P1)
|
||||
|
||||
**Goal**: The upload form has a visually distinct drop-zone, visible in-progress state ("Uploading…" + spinner), a success banner with auto-dismiss, distinct validation vs. network error messages, and a clearly disabled button style.
|
||||
|
||||
**Independent Test**: Navigate to `/upload` — confirm dashed drop-zone border. Select a large file and click Upload — confirm button shows "Uploading…" and is disabled. After success — confirm green banner appears then disappears after 4 s. Attempt to upload a `.txt` file — confirm "Unsupported file type" inline error.
|
||||
|
||||
### Tests for User Story 2 (TDD — write first, confirm failure before T016) ⚠️
|
||||
|
||||
- [X] T014 [US2] Add component tests for `loading` button-disabled state, "Uploading…" label, `showSuccess` banner visibility, auto-dismiss timer, validation error message, and network error message in `ui/src/app/upload/upload.component.spec.ts`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T015 [P] [US2] Replace all hardcoded colour values with CSS token variables in the component styles block of `ui/src/app/upload/upload.component.ts`
|
||||
- [X] T016 [US2] Style the drop-zone with a dashed `--accent`-coloured border at 40% opacity; add an active drag state that brightens the border to full `--accent` in `ui/src/app/upload/upload.component.ts`
|
||||
- [X] T017 [US2] Change submit button label to "Uploading…" and add a CSS spinner `<span>` inside the button while `loading = true` in `ui/src/app/upload/upload.component.ts`
|
||||
- [X] T018 [US2] Add `showSuccess = false` and `uploadedFilename = ''`; after a successful upload set both, show a green-tinted banner with filename, "Upload another" link, and "View in library" routerLink, then auto-dismiss after 4 s using `setTimeout` in `ui/src/app/upload/upload.component.ts`
|
||||
- [X] T019 [US2] Show distinct inline error messages: validation errors (wrong type/size from API) show the specific problem; network/server errors show a generic retry message — both rendered below the form without a page reload in `ui/src/app/upload/upload.component.ts`
|
||||
- [X] T020 [US2] Apply `--text-muted` colour and `opacity: 0.5` to the disabled button style to make the disabled state visually unmistakable in `ui/src/app/upload/upload.component.ts`
|
||||
|
||||
**Checkpoint**: Upload form communicates every state clearly and prevents duplicate submission.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Detail Page Is Well Organised (Priority: P1)
|
||||
|
||||
**Goal**: The detail view has a loading skeleton, a network error state with retry, a styled not-found card with back link, a grouped "Owner actions" panel for write controls, danger-styled tag errors, and a broken-image fallback.
|
||||
|
||||
**Independent Test**: Throttle to Slow 3G and navigate to `/images/<id>` — confirm skeleton appears. Stop the API and hard-refresh a detail page — confirm error card with retry (not a blank page). Navigate to `/images/00000000-0000-0000-0000-000000000000` — confirm not-found card with "Back to library" button. Log in and open a detail page — confirm write controls are grouped. Open detail page while logged out — confirm write controls absent. Add tag with `!` character — confirm danger-coloured inline error.
|
||||
|
||||
### Tests for User Story 3 (TDD — write first, confirm failure before T023) ⚠️
|
||||
|
||||
- [X] T021 [US3] Add component tests for skeleton visibility while `loading=true`, network error card when fetch fails (non-404), not-found card when `!image && !loading && !error`, tag error `--danger` style application, and `onImgError` handler in `ui/src/app/detail/detail.component.spec.ts`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T022 [P] [US3] Replace all hardcoded colour values with CSS token variables in the component styles block of `ui/src/app/detail/detail.component.ts`
|
||||
- [X] T023 [US3] Add skeleton loading layout while `loading = true`: a full-width grey `.skeleton` rectangle for the image area and two rows of `.skeleton` chip placeholders below in `ui/src/app/detail/detail.component.ts`
|
||||
- [X] T024 [US3] Add `error = false` flag; set it on API fetch failure (non-404 errors); render an error card with plain-language "Failed to load image" message, "Back to library" link, and a "Retry" button that calls the fetch again — distinct from the not-found state in `ui/src/app/detail/detail.component.ts`
|
||||
- [X] T025 [US3] Replace the plain `!image && !loading && !error` paragraph with a styled not-found card: centred layout, muted Unicode icon, "Image not found" `<h2>`, and a "Back to library" `routerLink="/"` button in `ui/src/app/detail/detail.component.ts`
|
||||
- [X] T026 [US3] Wrap the tag-edit input and delete button in a visually distinct "Owner actions" `<section>` with a `--surface` panel, `--border` top separator, and token-based padding/gap in `ui/src/app/detail/detail.component.ts`
|
||||
- [X] T027 [US3] Apply `color: var(--danger)` and `border-left: 3px solid var(--danger)` with left-padding to the `tagError` inline error element in `ui/src/app/detail/detail.component.ts`
|
||||
- [X] T028 [US3] Add `(error)="onImgError($event)"` on the full-size `<img>` and implement `onImgError` with an inline SVG broken-link placeholder (guard against recursive fallback) in `ui/src/app/detail/detail.component.ts`
|
||||
|
||||
**Checkpoint**: Detail page handles all states (loading, error, not-found, success) gracefully and clearly separates read from write content.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Login Page Matches the Design (Priority: P2)
|
||||
|
||||
**Goal**: The login page uses the shared dark design system, displays field-level validation errors and server error messages in `--danger` colour without a page reload, shows a single server error below the form on bad credentials, and disables the button with "Signing in…" label while in-flight.
|
||||
|
||||
**Independent Test**: Navigate to `/login` — confirm dark background, surface card, same font as library. Click Sign In with empty fields — confirm field-level errors without page reload. Enter wrong credentials — confirm single error message below form in danger colour; fields retain values. Throttle network and submit valid credentials — confirm button shows "Signing in…" and is disabled.
|
||||
|
||||
### Tests for User Story 4 (TDD — write first, confirm failure before T031) ⚠️
|
||||
|
||||
- [X] T029 [US4] Add component tests for field-level validation error display on empty submit, `errorMessage` server error paragraph visibility after failed login, "Signing in…" button label while `loading=true`, and fields-not-cleared behaviour in `ui/src/app/login/login.component.spec.ts`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T030 [P] [US4] Replace all hardcoded colour values with CSS token variables in the component styles block of `ui/src/app/login/login.component.ts`
|
||||
- [X] T031 [US4] Centre the login card vertically (`height: 100vh; display: flex; align-items: center; justify-content: center`) and wrap the form in a `--surface` card with `--radius` border-radius and a `1px solid var(--border)` border in `ui/src/app/login/login.component.ts`
|
||||
- [X] T032 [US4] Apply `color: var(--danger)` to field-level reactive-form validation error `<span>` elements for both username and password fields, and to the `errorMessage` server error paragraph below the form in `ui/src/app/login/login.component.ts`
|
||||
- [X] T033 [US4] Change submit button label to "Signing in…" and add `disabled` attribute while `loading = true`; style the button with `--accent` background and `--accent-text` foreground matching other views in `ui/src/app/login/login.component.ts`
|
||||
|
||||
**Checkpoint**: Login page is visually consistent with the rest of the app and communicates all form states.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 — App Shell Is Consistent (Priority: P2)
|
||||
|
||||
**Goal**: Every page shares a 48 px fixed-height header with the app name on the left and the sign-out control on the right (when authenticated). The header uses `--surface` background and `--border` bottom border and does not reflow page content on auth state change.
|
||||
|
||||
**Independent Test**: Log in and navigate between library, upload, and detail — confirm identical 48 px header on all pages. Log out — confirm sign-out control disappears but header height is unchanged. Visit library without logging in — confirm header is present but sign-out control absent.
|
||||
|
||||
### Tests for User Story 5 (TDD — write first, confirm failure before T036) ⚠️
|
||||
|
||||
- [X] T034 [US5] Add component tests for header 48 px height, sign-out button visibility when authenticated, sign-out button absence when unauthenticated, sign-out action redirecting to `/login`, and header height unchanged between auth states in `ui/src/app/app.component.spec.ts`
|
||||
|
||||
### Implementation for User Story 5
|
||||
|
||||
- [X] T035 [P] [US5] Replace all hardcoded colour values with CSS token variables in the component styles block of `ui/src/app/app.component.ts`
|
||||
- [X] T036 [US5] Style the header `<header>` element with `height: 48px`, `background: var(--surface)`, `border-bottom: 1px solid var(--border)`, and a flex layout placing the app name text on the left and the sign-out button on the right; use `color: var(--text-muted)` and a token-based hover state for the sign-out button; ensure `height` is declared on the host element so the 48 px is preserved regardless of sign-out button visibility in `ui/src/app/app.component.ts`
|
||||
|
||||
**Checkpoint**: All five views share a coherent shell. Application feels like one product.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Linting gate, build validation, responsive checks, and visual acceptance walk-through across all milestones.
|
||||
|
||||
- [X] T037 [P] Run `ng lint` in `ui/` and confirm zero ESLint and Prettier violations across all modified component files (§7.3 gate)
|
||||
- [X] T038 [P] Run `ng build` in `ui/` and confirm zero TypeScript or template errors across all modified components
|
||||
- [ ] T039 [P] Run `ng test --watch=false` in `ui/` (or equivalent build-time check) and confirm all new component spec tests pass with zero regressions
|
||||
- [ ] T040 [P] Verify upload form layout at 375 px viewport (Chrome DevTools device toolbar → iPhone SE): confirm no horizontal scrollbar and all form elements are usable in `ui/src/app/upload/upload.component.ts`
|
||||
- [ ] T041 [P] Verify detail page layout at 375 px viewport: confirm image, tags, and (when authenticated) owner actions are all visible without horizontal overflow in `ui/src/app/detail/detail.component.ts`
|
||||
- [ ] T042 Walk through `specs/005-ui-polish/quickstart.md` scenarios M1–M6 in a running `docker compose up` stack and confirm every visual acceptance criterion passes
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Phase 1 — **BLOCKS all user story phases**
|
||||
- **User Stories (Phases 3–7)**: ALL depend on Phase 2 (T004 gate); independent of each other
|
||||
- **Polish (Phase 8)**: Depends on all desired user story phases completing
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 Library (Phase 3, P1)**: Can start after Phase 2 — no dependency on US2–US5
|
||||
- **US2 Upload (Phase 4, P1)**: Can start after Phase 2 — no dependency on US1, US3–US5
|
||||
- **US3 Detail (Phase 5, P1)**: Can start after Phase 2 — no dependency on US1–US2, US4–US5
|
||||
- **US4 Login (Phase 6, P2)**: Can start after Phase 2 — no dependency on US1–US3, US5
|
||||
- **US5 App Shell (Phase 7, P2)**: Can start after Phase 2 — best done last as it wraps all views, but technically independent
|
||||
|
||||
### Within Each User Story Phase
|
||||
|
||||
1. Write component spec tests first (TDD) — they MUST fail before implementation
|
||||
2. Token replacement task [P] can run alongside test writing (different files)
|
||||
3. Implementation tasks follow in sequence (each new feature depends on the test that exercises it)
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### Running US1 (Library) startup in parallel
|
||||
|
||||
```text
|
||||
# Start simultaneously after Phase 2 completes:
|
||||
Task T005: Write library component tests (spec file)
|
||||
Task T006: Apply CSS tokens to library component styles
|
||||
```
|
||||
|
||||
### Running all three P1 stories in parallel (Phase 3, 4, 5)
|
||||
|
||||
```text
|
||||
# All can start simultaneously after T004 (Phase 2 gate):
|
||||
Phase 3 (US1): T005 → T006/T007 → T008 → T009 → T010 → T011 → T012 → T013
|
||||
Phase 4 (US2): T014 → T015/T016 → T017 → T018 → T019 → T020
|
||||
Phase 5 (US3): T021 → T022/T023 → T024 → T025 → T026 → T027 → T028
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (US1 + US2 + US3 — all P1 stories)
|
||||
|
||||
1. Complete Phase 1: Baseline verification
|
||||
2. Complete Phase 2: Design token layer (CRITICAL gate)
|
||||
3. Complete Phases 3, 4, 5 in parallel or sequence (all P1)
|
||||
4. **STOP and VALIDATE**: Run quickstart.md M1–M4 scenarios
|
||||
5. Add Phase 6 (US4 Login) and Phase 7 (US5 Shell) for complete polish
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Phase 1 + Phase 2 → Token layer live (no visible change)
|
||||
2. Phase 3 (US1) → Library feels complete → Demo
|
||||
3. Phase 4 (US2) → Upload flow polished → Demo
|
||||
4. Phase 5 (US3) → Detail page organised → Demo
|
||||
5. Phase 6 (US4) + Phase 7 (US5) → Full design system applied → Final demo
|
||||
6. Phase 8 → Lint clean, build clean, all tests pass, quickstart validated
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks have no file conflicts with other concurrent `[P]` tasks in the same phase
|
||||
- TDD order is mandatory: spec tests must be written and confirmed failing before implementation tasks
|
||||
- All five component files are standalone Angular components — changes are isolated
|
||||
- `ng build` is the type-check gate; Karma tests require the full Docker stack for browser runner
|
||||
- No new npm dependencies are introduced in any task
|
||||
- Commit after each milestone (M1–M6) for clean rollback points
|
||||
4
ui/.prettierignore
Normal file
4
ui/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
coverage/
|
||||
package-lock.json
|
||||
@@ -4,6 +4,9 @@ const tseslint = require("typescript-eslint");
|
||||
const angular = require("angular-eslint");
|
||||
|
||||
module.exports = tseslint.config(
|
||||
{
|
||||
ignores: ["dist/", "node_modules/", "coverage/", "*.min.js"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
extends: [
|
||||
|
||||
@@ -1,25 +1,86 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
let authSpy: jasmine.SpyObj<AuthService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
authSpy = jasmine.createSpyObj('AuthService', ['isAuthenticated', 'logout']);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent],
|
||||
providers: [provideRouter(routes)],
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
{ provide: AuthService, useValue: authSpy },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
authSpy.isAuthenticated.and.returnValue(false);
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have title reactbin-ui', () => {
|
||||
authSpy.isAuthenticated.and.returnValue(false);
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('reactbin-ui');
|
||||
expect(fixture.componentInstance.title).toEqual('reactbin-ui');
|
||||
});
|
||||
|
||||
it('header is present when authenticated', () => {
|
||||
authSpy.isAuthenticated.and.returnValue(true);
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const header = (fixture.nativeElement as HTMLElement).querySelector('header.app-header');
|
||||
expect(header).not.toBeNull();
|
||||
});
|
||||
|
||||
it('header is present when not authenticated', () => {
|
||||
authSpy.isAuthenticated.and.returnValue(false);
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const header = (fixture.nativeElement as HTMLElement).querySelector('header.app-header');
|
||||
expect(header).not.toBeNull();
|
||||
});
|
||||
|
||||
it('sign-out button is visible when authenticated', () => {
|
||||
authSpy.isAuthenticated.and.returnValue(true);
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const btn = (fixture.nativeElement as HTMLElement).querySelector('.logout-btn');
|
||||
expect(btn).not.toBeNull();
|
||||
});
|
||||
|
||||
it('sign-out button is absent when not authenticated', () => {
|
||||
authSpy.isAuthenticated.and.returnValue(false);
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const btn = (fixture.nativeElement as HTMLElement).querySelector('.logout-btn');
|
||||
expect(btn).toBeNull();
|
||||
});
|
||||
|
||||
it('onLogout calls auth.logout and navigates to /login', () => {
|
||||
authSpy.isAuthenticated.and.returnValue(true);
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate');
|
||||
fixture.componentInstance.onLogout();
|
||||
expect(authSpy.logout).toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
|
||||
it('header height is 48px', () => {
|
||||
authSpy.isAuthenticated.and.returnValue(true);
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const header = (fixture.nativeElement as HTMLElement).querySelector('header.app-header') as HTMLElement;
|
||||
// The CSS declares height: 48px; we verify the class is applied correctly via the element presence
|
||||
// (actual computed styles require a real browser/DOM environment)
|
||||
expect(header.classList.contains('app-header')).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,15 +8,35 @@ import { AuthService } from './auth/auth.service';
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet],
|
||||
template: `
|
||||
<header class="app-header" *ngIf="auth.isAuthenticated()">
|
||||
<button class="logout-btn" (click)="onLogout()">Sign out</button>
|
||||
<header class="app-header">
|
||||
<span class="app-name">Reactbin</span>
|
||||
<button *ngIf="auth.isAuthenticated()" class="logout-btn" (click)="onLogout()">Sign out</button>
|
||||
</header>
|
||||
<router-outlet />
|
||||
`,
|
||||
styles: [`
|
||||
.app-header { display: flex; justify-content: flex-end; padding: 8px 16px; background: #1a1a1a; border-bottom: 1px solid #333; }
|
||||
.logout-btn { background: none; border: 1px solid #555; color: #aaa; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 0.9rem; }
|
||||
.logout-btn:hover { border-color: #aaa; color: #e0e0e0; }
|
||||
:host { display: block; }
|
||||
.app-header {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.app-name { font-weight: 600; font-size: 1rem; color: var(--text); letter-spacing: 0.02em; }
|
||||
.logout-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: border-color var(--transition), color var(--transition);
|
||||
}
|
||||
.logout-btn:hover { border-color: var(--border-focus); color: var(--text); }
|
||||
`],
|
||||
})
|
||||
export class AppComponent {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { AuthService } from './auth.service';
|
||||
|
||||
describe('authGuard', () => {
|
||||
let authService: jasmine.SpyObj<AuthService>;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(() => {
|
||||
authService = jasmine.createSpyObj('AuthService', ['isAuthenticated']);
|
||||
@@ -18,8 +17,6 @@ describe('authGuard', () => {
|
||||
{ provide: AuthService, useValue: authService },
|
||||
],
|
||||
});
|
||||
|
||||
router = TestBed.inject(Router);
|
||||
});
|
||||
|
||||
it('redirects to login when not authenticated', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClient, HttpErrorResponse, provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { authInterceptor } from './auth.interceptor';
|
||||
@@ -48,6 +48,7 @@ describe('authInterceptor', () => {
|
||||
|
||||
it('redirects to login on 401 response', () => {
|
||||
authService.getToken.and.returnValue('test-token');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
http.get('/api/v1/images').subscribe({ error: () => {} });
|
||||
const req = httpMock.expectOne('/api/v1/images');
|
||||
req.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
@@ -2,27 +2,19 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { of, throwError, Subject } from 'rxjs';
|
||||
import { DetailComponent } from './detail.component';
|
||||
import { ImageService } from '../services/image.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,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
tags: ['cat', 'funny'],
|
||||
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, created_at: '2026-01-01T00:00:00Z', tags: ['cat', 'funny'],
|
||||
};
|
||||
|
||||
describe('DetailComponent', () => {
|
||||
function setup(imageId = 'img-1') {
|
||||
function setup(imageId = 'img-1', imageResponse = of(MOCK_IMAGE)) {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [DetailComponent],
|
||||
providers: [
|
||||
@@ -33,13 +25,13 @@ describe('DetailComponent', () => {
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(DetailComponent);
|
||||
const component = fixture.componentInstance;
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'get').and.returnValue(of(MOCK_IMAGE));
|
||||
spyOn(imgSvc, 'get').and.returnValue(imageResponse);
|
||||
fixture.detectChanges();
|
||||
return { fixture, component, imgSvc };
|
||||
return { fixture, component: fixture.componentInstance, imgSvc };
|
||||
}
|
||||
|
||||
// Existing tests preserved
|
||||
it('should call PATCH with removed tag absent when chip × is clicked', () => {
|
||||
const { component, imgSvc } = setup();
|
||||
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] }));
|
||||
@@ -80,4 +72,74 @@ describe('DetailComponent', () => {
|
||||
component.goBack();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||
});
|
||||
|
||||
// New polish tests
|
||||
|
||||
it('skeleton is visible while loading is true', () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [DetailComponent],
|
||||
providers: [
|
||||
provideHttpClient(), provideHttpClientTesting(), provideRouter(routes),
|
||||
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 'img-1' } } } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(DetailComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
// Don't emit — keep loading state
|
||||
spyOn(imgSvc, 'get').and.returnValue(new Subject());
|
||||
fixture.componentInstance.loading = true;
|
||||
fixture.detectChanges();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.image-skeleton')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('error card shown when error is true and loading is false', () => {
|
||||
const fixture = (() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [DetailComponent],
|
||||
providers: [
|
||||
provideHttpClient(), provideHttpClientTesting(), provideRouter(routes),
|
||||
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 'img-1' } } } },
|
||||
],
|
||||
}).compileComponents();
|
||||
return TestBed.createComponent(DetailComponent);
|
||||
})();
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'get').and.returnValue(throwError(() => ({ status: 500 })));
|
||||
fixture.detectChanges();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.fetch-error-card')).not.toBeNull();
|
||||
});
|
||||
|
||||
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();
|
||||
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';
|
||||
fixture.detectChanges();
|
||||
const errEl = (fixture.nativeElement as HTMLElement).querySelector('.tag-error');
|
||||
expect(errEl).not.toBeNull();
|
||||
});
|
||||
|
||||
it('onImgError sets src to placeholder SVG', () => {
|
||||
const { fixture } = setup();
|
||||
const imgEl = document.createElement('img');
|
||||
imgEl.src = 'http://example.com/image.jpg';
|
||||
fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event);
|
||||
expect(imgEl.src).toContain('data:image/svg+xml');
|
||||
});
|
||||
|
||||
it('onImgError does not recurse when src already is a data URI', () => {
|
||||
const { fixture } = setup();
|
||||
const imgEl = document.createElement('img');
|
||||
imgEl.src = 'data:image/svg+xml,placeholder';
|
||||
const before = imgEl.src;
|
||||
fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event);
|
||||
expect(imgEl.src).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,58 @@
|
||||
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { ImageRecord, ImageService } from '../services/image.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500"><rect width="800" height="500" fill="%23111"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="48" fill="%23444">🔗</text></svg>`;
|
||||
|
||||
@Component({
|
||||
selector: 'app-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="detail-page" *ngIf="image">
|
||||
<button class="back-btn" (click)="goBack()">← Back</button>
|
||||
<!-- Loading skeleton -->
|
||||
<div class="detail-page" *ngIf="loading">
|
||||
<div class="skeleton image-skeleton"></div>
|
||||
<div class="chip-row-skeleton">
|
||||
<div *ngFor="let _ of skeletonChips" class="skeleton chip-skeleton"></div>
|
||||
</div>
|
||||
<div class="chip-row-skeleton">
|
||||
<div *ngFor="let _ of skeletonChips" class="skeleton chip-skeleton short"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network error state -->
|
||||
<div class="fetch-error-card" *ngIf="error && !loading">
|
||||
<span class="error-icon">⚠</span>
|
||||
<p>Failed to load image. Please check your connection.</p>
|
||||
<div class="error-actions">
|
||||
<button class="retry-btn" (click)="retry()">Retry</button>
|
||||
<a routerLink="/" class="back-link">Back to library</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not-found state -->
|
||||
<div class="not-found-card" *ngIf="!image && !loading && !error">
|
||||
<span class="not-found-icon">✦</span>
|
||||
<h2>Image not found</h2>
|
||||
<p>This image may have been deleted or the URL is incorrect.</p>
|
||||
<a routerLink="/" class="back-btn">Back to library</a>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="detail-page" *ngIf="image && !loading">
|
||||
<button class="back-btn-inline" (click)="goBack()">← Back</button>
|
||||
<h2>{{ image.filename }}</h2>
|
||||
|
||||
<img class="full-image" [src]="imageService.getFileUrl(image.id)" [alt]="image.filename" />
|
||||
<img
|
||||
class="full-image"
|
||||
[src]="imageService.getFileUrl(image.id)"
|
||||
[alt]="image.filename"
|
||||
(error)="onImgError($event)"
|
||||
/>
|
||||
|
||||
<section class="tags-section">
|
||||
<h3>Tags</h3>
|
||||
@@ -24,7 +61,11 @@ import { AuthService } from '../auth/auth.service';
|
||||
{{ tag }} <button *ngIf="auth.isAuthenticated()" (click)="removeTag(tag)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="add-tag" *ngIf="auth.isAuthenticated()">
|
||||
<p class="tag-error" *ngIf="tagError">{{ tagError }}</p>
|
||||
</section>
|
||||
|
||||
<section class="owner-actions" *ngIf="auth.isAuthenticated()">
|
||||
<div class="add-tag">
|
||||
<input
|
||||
[(ngModel)]="newTagInput"
|
||||
placeholder="Add tag…"
|
||||
@@ -32,11 +73,9 @@ import { AuthService } from '../auth/auth.service';
|
||||
(blur)="onBlur()"
|
||||
/>
|
||||
</div>
|
||||
<p class="tag-error" *ngIf="tagError">{{ tagError }}</p>
|
||||
<button class="delete-btn" (click)="showDeleteDialog = true">Delete Image</button>
|
||||
</section>
|
||||
|
||||
<button *ngIf="auth.isAuthenticated()" class="delete-btn" (click)="showDeleteDialog = true">Delete Image</button>
|
||||
|
||||
<div class="dialog-overlay" *ngIf="showDeleteDialog">
|
||||
<div class="dialog">
|
||||
<p>Permanently delete this image?</p>
|
||||
@@ -45,34 +84,78 @@ import { AuthService } from '../auth/auth.service';
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p *ngIf="!image && !loading" class="not-found">Image not found.</p>
|
||||
`,
|
||||
styles: [`
|
||||
.detail-page { max-width: 900px; margin: 32px auto; padding: 0 16px; }
|
||||
.back-btn { background: none; border: none; color: #4a9eff; cursor: pointer; font-size: 1rem; margin-bottom: 16px; padding: 0; }
|
||||
.full-image { width: 100%; max-height: 70vh; object-fit: contain; background: #111; border-radius: 8px; display: block; }
|
||||
|
||||
/* Skeleton */
|
||||
.image-skeleton { width: 100%; height: 400px; margin-bottom: 16px; }
|
||||
.chip-row-skeleton { display: flex; gap: 8px; margin-bottom: 10px; padding: 0 16px; }
|
||||
.chip-skeleton { width: 64px; height: 28px; border-radius: var(--radius-chip); }
|
||||
.chip-skeleton.short { width: 48px; }
|
||||
|
||||
/* Network error card */
|
||||
.fetch-error-card {
|
||||
max-width: 520px; margin: 80px auto; text-align: center;
|
||||
padding: 40px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
}
|
||||
.error-icon { display: block; font-size: 2rem; color: var(--danger); margin-bottom: 12px; }
|
||||
.error-actions { display: flex; justify-content: center; gap: 16px; margin-top: 20px; }
|
||||
.retry-btn { padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; }
|
||||
.retry-btn:hover { border-color: var(--border-focus); }
|
||||
.back-link { color: var(--accent); text-decoration: none; line-height: 2.2; }
|
||||
|
||||
/* Not-found card */
|
||||
.not-found-card {
|
||||
max-width: 480px; margin: 80px auto; text-align: center;
|
||||
padding: 48px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
}
|
||||
.not-found-icon { display: block; font-size: 2.5rem; color: var(--text-muted); margin-bottom: 16px; }
|
||||
.not-found-card h2 { margin-bottom: 8px; }
|
||||
.not-found-card p { color: var(--text-muted); margin-bottom: 24px; }
|
||||
|
||||
/* Main detail */
|
||||
.back-btn-inline { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 1rem; margin-bottom: 16px; padding: 0; }
|
||||
.back-btn {
|
||||
display: inline-block; margin-top: 16px; padding: 10px 24px;
|
||||
background: var(--surface-raised); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.back-btn:hover { border-color: var(--border-focus); }
|
||||
.full-image { width: 100%; max-height: 70vh; object-fit: contain; background: #111; border-radius: var(--radius); display: block; }
|
||||
.tags-section { margin-top: 24px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
||||
.chip { background: #333; padding: 4px 12px; border-radius: 14px; display: flex; align-items: center; gap: 6px; }
|
||||
.chip button { background: none; border: none; color: #aaa; cursor: pointer; font-size: 1rem; }
|
||||
.add-tag input { padding: 8px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 4px; width: 200px; }
|
||||
.tag-error { color: #ff6b6b; font-size: 0.85rem; margin-top: 6px; }
|
||||
.delete-btn { margin-top: 32px; padding: 10px 24px; background: #c0392b; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
|
||||
.chip { background: var(--surface-raised); padding: 4px 12px; border-radius: var(--radius-chip); display: flex; align-items: center; gap: 6px; }
|
||||
.chip button { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1rem; }
|
||||
.tag-error { color: var(--danger); font-size: 0.85rem; margin-top: 6px; border-left: 3px solid var(--danger); padding-left: 10px; }
|
||||
|
||||
/* Owner actions panel */
|
||||
.owner-actions {
|
||||
margin-top: 24px; padding: 20px;
|
||||
background: var(--surface); border-top: 1px solid var(--border); border-radius: var(--radius);
|
||||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.add-tag input { padding: 8px; background: var(--surface-raised); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); width: 200px; }
|
||||
.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; }
|
||||
|
||||
/* 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: #1a1a1a; padding: 32px; border-radius: 10px; text-align: center; }
|
||||
.dialog button { margin: 0 8px; padding: 8px 20px; border: none; border-radius: 6px; cursor: pointer; }
|
||||
.dialog button:first-of-type { background: #c0392b; color: #fff; }
|
||||
.dialog button:last-of-type { background: #444; color: #e0e0e0; }
|
||||
.not-found { text-align: center; color: #666; padding: 60px; }
|
||||
.dialog { background: var(--surface); padding: 32px; border-radius: 10px; text-align: center; }
|
||||
.dialog button { margin: 12px 8px 0; padding: 8px 20px; border: none; border-radius: var(--radius); cursor: pointer; }
|
||||
.dialog button:first-of-type { background: var(--danger); color: var(--danger-text); }
|
||||
.dialog button:last-of-type { background: var(--surface-raised); color: var(--text); }
|
||||
`],
|
||||
})
|
||||
export class DetailComponent implements OnInit {
|
||||
image: ImageRecord | null = null;
|
||||
loading = true;
|
||||
error = false;
|
||||
newTagInput = '';
|
||||
tagError = '';
|
||||
showDeleteDialog = false;
|
||||
readonly skeletonChips = Array(4).fill(null);
|
||||
private currentId = '';
|
||||
|
||||
constructor(
|
||||
public imageService: ImageService,
|
||||
@@ -85,9 +168,35 @@ export class DetailComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (!id) { this.loading = false; return; }
|
||||
this.currentId = id;
|
||||
this.fetchImage(id);
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.error = false;
|
||||
this.loading = true;
|
||||
this.cdr.markForCheck();
|
||||
this.fetchImage(this.currentId);
|
||||
}
|
||||
|
||||
private fetchImage(id: string): void {
|
||||
this.imageService.get(id).subscribe({
|
||||
next: (img) => { this.image = img; this.loading = false; this.cdr.markForCheck(); },
|
||||
error: () => { this.loading = false; this.cdr.markForCheck(); },
|
||||
next: (img) => {
|
||||
this.image = img;
|
||||
this.loading = false;
|
||||
this.error = false;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
if (err?.status === 404) {
|
||||
this.image = null;
|
||||
this.error = false;
|
||||
} else {
|
||||
this.error = true;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,4 +236,11 @@ export class DetailComponent implements OnInit {
|
||||
}
|
||||
|
||||
goBack(): void { this.router.navigate(['/']); }
|
||||
|
||||
onImgError(event: Event): void {
|
||||
const img = event.target as HTMLImageElement;
|
||||
if (!img.src.startsWith('data:')) {
|
||||
img.src = PLACEHOLDER_SVG;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { LibraryComponent } from './library.component';
|
||||
import { ImageService } from '../services/image.service';
|
||||
import { routes } from '../app.routes';
|
||||
|
||||
const EMPTY_PAGE = { items: [], total: 0, limit: 50, 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, created_at: '' }],
|
||||
total: 1, limit: 50, offset: 0,
|
||||
};
|
||||
|
||||
describe('LibraryComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -17,33 +23,88 @@ describe('LibraryComponent', () => {
|
||||
|
||||
it('should render image grid from service response', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const component = fixture.componentInstance;
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(
|
||||
of({
|
||||
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, created_at: '' },
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(ONE_IMAGE));
|
||||
fixture.detectChanges();
|
||||
const de = fixture.nativeElement as HTMLElement;
|
||||
expect(de.querySelectorAll('.image-card').length).toBe(1);
|
||||
expect((fixture.nativeElement as HTMLElement).querySelectorAll('.image-card').length).toBe(1);
|
||||
});
|
||||
|
||||
it('should trigger new API call with tags param on filter change', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const component = fixture.componentInstance;
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
const listSpy = spyOn(imgSvc, 'list').and.returnValue(
|
||||
of({ items: [], total: 0, limit: 50, offset: 0 })
|
||||
);
|
||||
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
|
||||
fixture.detectChanges();
|
||||
|
||||
component.applyFilter(['cat', 'funny']);
|
||||
fixture.componentInstance.applyFilter(['cat', 'funny']);
|
||||
expect(listSpy).toHaveBeenCalledWith(['cat', 'funny'], jasmine.any(Number), jasmine.any(Number));
|
||||
});
|
||||
|
||||
it('showSpinner is false initially', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.showSpinner).toBeFalse();
|
||||
});
|
||||
|
||||
it('renders 8 skeleton cards while showSpinner is true', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
fixture.componentInstance.showSpinner = true;
|
||||
fixture.detectChanges();
|
||||
const skeletons = (fixture.nativeElement as HTMLElement).querySelectorAll('.card-skeleton');
|
||||
expect(skeletons.length).toBe(8);
|
||||
});
|
||||
|
||||
it('error is false initially', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.error).toBeFalse();
|
||||
});
|
||||
|
||||
it('shows error card when error is true', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
fixture.componentInstance.error = true;
|
||||
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;
|
||||
fixture.detectChanges();
|
||||
spyOn(fixture.componentInstance, 'load');
|
||||
const retryBtn = (fixture.nativeElement as HTMLElement).querySelector('.error-card .retry-btn') as HTMLButtonElement;
|
||||
expect(retryBtn).not.toBeNull();
|
||||
retryBtn.click();
|
||||
expect(fixture.componentInstance.load).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('empty state contains routerLink to /upload', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
|
||||
fixture.detectChanges();
|
||||
const link = (fixture.nativeElement as HTMLElement).querySelector('.empty-state a[href="/upload"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('onImgError sets src to placeholder SVG', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgEl = document.createElement('img');
|
||||
imgEl.src = 'http://example.com/image.jpg';
|
||||
const event = { target: imgEl } as unknown as Event;
|
||||
fixture.componentInstance.onImgError(event);
|
||||
expect(imgEl.src).toContain('data:image/svg+xml');
|
||||
});
|
||||
|
||||
it('onImgError does not recurse when src already contains placeholder', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgEl = document.createElement('img');
|
||||
imgEl.src = 'data:image/svg+xml,placeholder';
|
||||
const originalSrc = imgEl.src;
|
||||
const event = { target: imgEl } as unknown as Event;
|
||||
fixture.componentInstance.onImgError(event);
|
||||
expect(imgEl.src).toBe(originalSrc);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,15 +5,18 @@ import {
|
||||
ChangeDetectorRef,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { Subject, debounceTime, distinctUntilChanged, share, timer } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { ImageRecord, ImageService } from '../services/image.service';
|
||||
import { TagService } from '../services/tag.service';
|
||||
|
||||
const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="160" viewBox="0 0 200 160"><rect width="200" height="160" fill="%23252525"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="32" fill="%23555">🖼</text></svg>`;
|
||||
|
||||
@Component({
|
||||
selector: 'app-library',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="library">
|
||||
@@ -34,49 +37,83 @@ import { TagService } from '../services/tag.service';
|
||||
</span>
|
||||
</div>
|
||||
<ul class="suggestions" *ngIf="suggestions.length">
|
||||
<li *ngFor="let s of suggestions" (click)="addFilter(s.name)">{{ s.name }} ({{ s.image_count }})</li>
|
||||
<li *ngFor="let s of suggestions" (click)="addFilter(s.name)" (keydown.enter)="addFilter(s.name)" tabindex="0" role="option" [attr.aria-selected]="false">{{ s.name }} ({{ s.image_count }})</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div *ngIf="images.length === 0 && !loading" class="empty-state">
|
||||
<p>{{ activeFilters.length ? 'No images match these filters.' : 'No images yet. Upload your first!' }}</p>
|
||||
<!-- Skeleton loading grid -->
|
||||
<div *ngIf="showSpinner" class="grid">
|
||||
<div *ngFor="let _ of skeletonItems" class="image-card skeleton card-skeleton"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Error state -->
|
||||
<div *ngIf="error && !showSpinner" class="error-card">
|
||||
<p>Failed to load images. Please check your connection.</p>
|
||||
<button class="retry-btn" (click)="load()">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div *ngIf="images.length === 0 && !showSpinner && !error" class="empty-state">
|
||||
<span class="empty-icon">✦</span>
|
||||
<p *ngIf="activeFilters.length">No images match these filters.</p>
|
||||
<p *ngIf="!activeFilters.length">No images yet.</p>
|
||||
<a *ngIf="!activeFilters.length" routerLink="/upload" class="upload-link">Upload your first image</a>
|
||||
</div>
|
||||
|
||||
<!-- Image grid -->
|
||||
<div *ngIf="!showSpinner && !error" class="grid">
|
||||
<div
|
||||
*ngFor="let img of images"
|
||||
class="image-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="router.navigate(['/images', img.id])"
|
||||
(keydown.enter)="router.navigate(['/images', img.id])"
|
||||
>
|
||||
<img [src]="imageService.getThumbnailUrl(img.id)" [alt]="img.filename" loading="lazy" />
|
||||
<img
|
||||
[src]="imageService.getThumbnailUrl(img.id)"
|
||||
[alt]="img.filename"
|
||||
loading="lazy"
|
||||
(error)="onImgError($event)"
|
||||
/>
|
||||
<div class="tag-row">
|
||||
<span *ngFor="let tag of img.tags" class="chip small">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button *ngIf="hasMore" class="load-more" (click)="loadMore()">Load more</button>
|
||||
<button *ngIf="hasMore && !showSpinner && !error" class="load-more" (click)="loadMore()">Load more</button>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.library { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.upload-btn { padding: 8px 20px; background: #4a9eff; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
|
||||
.upload-btn { padding: 8px 20px; background: var(--accent); color: var(--accent-text); border: none; border-radius: var(--radius); cursor: pointer; font-weight: 600; }
|
||||
.filter-bar { position: relative; margin-bottom: 24px; }
|
||||
.filter-bar input { width: 100%; padding: 10px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 6px; }
|
||||
.filter-bar input { width: 100%; padding: 10px; background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); }
|
||||
.filter-bar input:focus { outline: none; border-color: var(--border-focus); }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.chip { background: #333; padding: 3px 10px; border-radius: 12px; font-size: 0.85rem; display: flex; align-items: center; gap: 4px; }
|
||||
.chip { background: var(--surface-raised); padding: 3px 10px; border-radius: var(--radius-chip); font-size: 0.85rem; display: flex; align-items: center; gap: 4px; }
|
||||
.chip.small { font-size: 0.75rem; padding: 2px 8px; }
|
||||
.chip button { background: none; border: none; color: #aaa; cursor: pointer; padding: 0; font-size: 1rem; }
|
||||
.suggestions { position: absolute; z-index: 10; background: #1a1a1a; border: 1px solid #444; list-style: none; width: 100%; max-height: 200px; overflow-y: auto; border-radius: 0 0 6px 6px; }
|
||||
.chip button { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 0; font-size: 1rem; }
|
||||
.suggestions { position: absolute; z-index: 10; background: var(--surface); border: 1px solid var(--border); list-style: none; width: 100%; max-height: 200px; overflow-y: auto; border-radius: 0 0 var(--radius) var(--radius); }
|
||||
.suggestions li { padding: 8px 12px; cursor: pointer; }
|
||||
.suggestions li:hover { background: #2a2a2a; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
||||
.image-card { cursor: pointer; background: #1a1a1a; border-radius: 8px; overflow: hidden; }
|
||||
.suggestions li:hover { background: var(--surface-raised); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
|
||||
.image-card { cursor: pointer; background: var(--surface); border-radius: var(--radius); overflow: hidden; transition: transform var(--transition), box-shadow var(--transition); }
|
||||
.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; }
|
||||
.empty-state { text-align: center; padding: 60px 0; color: #666; }
|
||||
.load-more { display: block; margin: 24px auto; padding: 10px 32px; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 6px; 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; }
|
||||
.upload-link:hover { text-decoration: underline; }
|
||||
.error-card { text-align: center; padding: 40px; background: var(--surface); border-radius: var(--radius); border: 1px solid var(--border); }
|
||||
.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; }
|
||||
`],
|
||||
})
|
||||
export class LibraryComponent implements OnInit {
|
||||
@@ -84,8 +121,10 @@ export class LibraryComponent implements OnInit {
|
||||
activeFilters: string[] = [];
|
||||
tagSearch = '';
|
||||
suggestions: { name: string; image_count: number }[] = [];
|
||||
loading = false;
|
||||
showSpinner = false;
|
||||
error = false;
|
||||
hasMore = false;
|
||||
readonly skeletonItems = Array(8).fill(null);
|
||||
private offset = 0;
|
||||
private readonly limit = 50;
|
||||
private readonly filterChange$ = new Subject<string>();
|
||||
@@ -98,7 +137,7 @@ export class LibraryComponent implements OnInit {
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadImages();
|
||||
this.load();
|
||||
this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => {
|
||||
if (q) {
|
||||
this.tagService.list(q, 10).subscribe((r) => {
|
||||
@@ -112,6 +151,29 @@ export class LibraryComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.error = false;
|
||||
const req$ = this.imageService.list(this.activeFilters, this.limit, this.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.showSpinner = false;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: () => {
|
||||
this.showSpinner = false;
|
||||
this.error = true;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onTagInput(event: Event): void {
|
||||
const val = (event.target as HTMLInputElement).value;
|
||||
this.tagSearch = val;
|
||||
@@ -136,21 +198,17 @@ export class LibraryComponent implements OnInit {
|
||||
this.activeFilters = tags;
|
||||
this.offset = 0;
|
||||
this.images = [];
|
||||
this.loadImages();
|
||||
}
|
||||
|
||||
loadImages(): void {
|
||||
this.loading = true;
|
||||
this.imageService.list(this.activeFilters, this.limit, this.offset).subscribe((res) => {
|
||||
this.images = [...this.images, ...res.items];
|
||||
this.offset += res.items.length;
|
||||
this.hasMore = this.offset < res.total;
|
||||
this.loading = false;
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
this.load();
|
||||
}
|
||||
|
||||
loadMore(): void {
|
||||
this.loadImages();
|
||||
this.load();
|
||||
}
|
||||
|
||||
onImgError(event: Event): void {
|
||||
const img = event.target as HTMLImageElement;
|
||||
if (!img.src.startsWith('data:')) {
|
||||
img.src = PLACEHOLDER_SVG;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,4 +60,48 @@ describe('LoginComponent', () => {
|
||||
tick();
|
||||
expect(authService.login).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
// New polish tests
|
||||
|
||||
it('submit button shows "Signing in…" and is disabled while loading', () => {
|
||||
const fixture = TestBed.createComponent(LoginComponent);
|
||||
const comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
comp.loading = true;
|
||||
fixture.detectChanges();
|
||||
const btn = (fixture.nativeElement as HTMLElement).querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
expect(btn.textContent?.trim()).toContain('Signing in');
|
||||
expect(btn.disabled).toBeTrue();
|
||||
});
|
||||
|
||||
it('field-level validation error shown for empty username on touched', () => {
|
||||
const fixture = TestBed.createComponent(LoginComponent);
|
||||
const comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
comp.form.get('username')!.markAsTouched();
|
||||
fixture.detectChanges();
|
||||
const err = (fixture.nativeElement as HTMLElement).querySelector('.validation-error');
|
||||
expect(err).not.toBeNull();
|
||||
});
|
||||
|
||||
it('errorMessage paragraph is visible when errorMessage is set', fakeAsync(() => {
|
||||
authService.login.and.returnValue(throwError(() => new HttpErrorResponse({ status: 401 })));
|
||||
component.form.setValue({ username: 'owner', password: 'wrong' });
|
||||
component.onSubmit();
|
||||
tick();
|
||||
const fixture = TestBed.createComponent(LoginComponent);
|
||||
fixture.componentInstance.errorMessage = component.errorMessage;
|
||||
fixture.detectChanges();
|
||||
const errPara = (fixture.nativeElement as HTMLElement).querySelector('.error-message');
|
||||
expect(errPara).not.toBeNull();
|
||||
}));
|
||||
|
||||
it('fields retain their values after a failed login', fakeAsync(() => {
|
||||
authService.login.and.returnValue(throwError(() => new HttpErrorResponse({ status: 401 })));
|
||||
component.form.setValue({ username: 'owner', password: 'wrong' });
|
||||
component.onSubmit();
|
||||
tick();
|
||||
expect(component.form.value.username).toBe('owner');
|
||||
expect(component.form.value.password).toBe('wrong');
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -10,29 +10,69 @@ import { AuthService } from '../auth/auth.service';
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
template: `
|
||||
<div class="login-page">
|
||||
<h1>Sign In</h1>
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate>
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" formControlName="username" />
|
||||
<span *ngIf="form.get('username')?.invalid && form.get('username')?.touched" class="validation-error">
|
||||
Username is required
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" formControlName="password" />
|
||||
<span *ngIf="form.get('password')?.invalid && form.get('password')?.touched" class="validation-error">
|
||||
Password is required
|
||||
</span>
|
||||
</div>
|
||||
<p *ngIf="errorMessage" class="error-message">{{ errorMessage }}</p>
|
||||
<button type="submit" [disabled]="loading">
|
||||
{{ loading ? 'Signing in…' : 'Sign In' }}
|
||||
</button>
|
||||
</form>
|
||||
<div class="login-card">
|
||||
<h1>Sign In</h1>
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate>
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" formControlName="username" autocomplete="username" />
|
||||
<span *ngIf="form.get('username')?.invalid && form.get('username')?.touched" class="validation-error">
|
||||
Username is required
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" formControlName="password" autocomplete="current-password" />
|
||||
<span *ngIf="form.get('password')?.invalid && form.get('password')?.touched" class="validation-error">
|
||||
Password is required
|
||||
</span>
|
||||
</div>
|
||||
<p *ngIf="errorMessage" class="error-message">{{ errorMessage }}</p>
|
||||
<button type="submit" [disabled]="loading">
|
||||
{{ loading ? 'Signing in…' : 'Sign In' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 40px 32px;
|
||||
}
|
||||
h1 { margin-bottom: 28px; font-size: 1.5rem; }
|
||||
.field { margin-bottom: 20px; }
|
||||
label { display: block; margin-bottom: 6px; font-size: 0.9rem; color: var(--text-muted); }
|
||||
input[type="text"], input[type="password"] {
|
||||
width: 100%; padding: 10px 12px;
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
color: var(--text); border-radius: var(--radius);
|
||||
font-size: 1rem; transition: border-color var(--transition);
|
||||
}
|
||||
input:focus { outline: none; border-color: var(--border-focus); }
|
||||
.validation-error { display: block; margin-top: 4px; font-size: 0.8rem; color: var(--danger); }
|
||||
.error-message { margin-bottom: 16px; color: var(--danger); font-size: 0.9rem; }
|
||||
button[type="submit"] {
|
||||
width: 100%; padding: 11px;
|
||||
background: var(--accent); color: var(--accent-text);
|
||||
border: none; border-radius: var(--radius);
|
||||
font-size: 1rem; font-weight: 600; cursor: pointer;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
button[type="submit"]:disabled { opacity: 0.5; cursor: default; }
|
||||
`],
|
||||
})
|
||||
export class LoginComponent {
|
||||
form: FormGroup;
|
||||
@@ -53,6 +93,7 @@ export class LoginComponent {
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
|
||||
@@ -2,19 +2,12 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { UploadComponent } from './upload.component';
|
||||
import { ImageService } from '../services/image.service';
|
||||
import { routes } from '../app.routes';
|
||||
|
||||
describe('UploadComponent', () => {
|
||||
let component: UploadComponent;
|
||||
|
||||
function makeImageService(overrides: Partial<ImageService> = {}): jasmine.SpyObj<ImageService> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return jasmine.createSpyObj<ImageService>('ImageService', { upload: of({} as any), ...overrides } as any);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [UploadComponent],
|
||||
@@ -26,22 +19,17 @@ describe('UploadComponent', () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.tagInput = 'CAT, Funny reaction';
|
||||
const parsed = component.parseTagInput(component.tagInput);
|
||||
expect(parsed).toEqual(['cat', 'funny', 'reaction']);
|
||||
expect(component.parseTagInput(component.tagInput)).toEqual(['cat', 'funny', 'reaction']);
|
||||
});
|
||||
|
||||
it('should split on commas', () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
const parsed = component.parseTagInput('a,b,c');
|
||||
expect(parsed).toEqual(['a', 'b', 'c']);
|
||||
expect(fixture.componentInstance.parseTagInput('a,b,c')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should filter empty tokens', () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
const parsed = component.parseTagInput(' ,, cat ,,');
|
||||
expect(parsed).toEqual(['cat']);
|
||||
expect(fixture.componentInstance.parseTagInput(' ,, cat ,,')).toEqual(['cat']);
|
||||
});
|
||||
|
||||
it('on duplicate response: shows toast and navigates to detail', async () => {
|
||||
@@ -49,13 +37,7 @@ describe('UploadComponent', () => {
|
||||
component = fixture.componentInstance;
|
||||
const router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate');
|
||||
|
||||
const mockSvc = makeImageService({
|
||||
upload: of({ id: 'abc', duplicate: true } as any),
|
||||
} as any);
|
||||
(component as any).imageService = mockSvc;
|
||||
|
||||
await component.handleUploadResponse({ id: 'abc', duplicate: true } as any);
|
||||
await component.handleUploadResponse({ id: 'abc', duplicate: true } as Parameters<typeof component.handleUploadResponse>[0]);
|
||||
expect(component.toastMessage).toContain('library');
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/images', 'abc']);
|
||||
});
|
||||
@@ -65,8 +47,7 @@ describe('UploadComponent', () => {
|
||||
component = fixture.componentInstance;
|
||||
const router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate');
|
||||
|
||||
await component.handleUploadResponse({ id: 'xyz', duplicate: false } as any);
|
||||
await component.handleUploadResponse({ id: 'xyz', duplicate: false } as Parameters<typeof component.handleUploadResponse>[0]);
|
||||
expect(component.toastMessage).toBeTruthy();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/images', 'xyz']);
|
||||
});
|
||||
@@ -76,9 +57,66 @@ describe('UploadComponent', () => {
|
||||
component = fixture.componentInstance;
|
||||
const router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate');
|
||||
|
||||
component.handleUploadError({ status: 422, error: { detail: 'bad file', code: 'invalid_mime_type' } });
|
||||
expect(component.errorMessage).toBeTruthy();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// New polish tests
|
||||
|
||||
it('submit button is disabled when no file is selected', () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
const btn = (fixture.nativeElement as HTMLElement).querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
expect(btn.disabled).toBeTrue();
|
||||
});
|
||||
|
||||
it('submit button shows "Uploading…" label while uploading is true', () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.uploading = true;
|
||||
fixture.detectChanges();
|
||||
const btn = (fixture.nativeElement as HTMLElement).querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
expect(btn.textContent).toContain('Uploading');
|
||||
expect(btn.disabled).toBeTrue();
|
||||
});
|
||||
|
||||
it('showSuccess banner is hidden initially', () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.success-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('showSuccess banner is visible when showSuccess is true', () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.showSuccess = true;
|
||||
component.uploadedFilename = 'photo.jpg';
|
||||
fixture.detectChanges();
|
||||
const banner = (fixture.nativeElement as HTMLElement).querySelector('.success-banner');
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner!.textContent).toContain('photo.jpg');
|
||||
});
|
||||
|
||||
it('shows validation error message for 422 response', () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
component.handleUploadError({ status: 422, error: { detail: 'Unsupported file type', code: 'invalid_mime_type' } });
|
||||
fixture.detectChanges();
|
||||
const err = (fixture.nativeElement as HTMLElement).querySelector('.error');
|
||||
expect(err!.textContent).toContain('Unsupported file type');
|
||||
});
|
||||
|
||||
it('shows generic error message for network error', () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
component.handleUploadError({ status: 500, error: null });
|
||||
fixture.detectChanges();
|
||||
const err = (fixture.nativeElement as HTMLElement).querySelector('.error');
|
||||
expect(err!.textContent).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { ImageRecord, ImageService } from '../services/image.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-upload',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="upload-page">
|
||||
@@ -15,45 +15,91 @@ import { ImageRecord, ImageService } from '../services/image.service';
|
||||
|
||||
<div
|
||||
class="drop-zone"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[class.drag-over]="isDragOver"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="isDragOver = false"
|
||||
(drop)="onDrop($event)"
|
||||
(click)="fileInput.click()"
|
||||
(keydown.enter)="fileInput.click()"
|
||||
(keydown.space)="fileInput.click()"
|
||||
>
|
||||
<span class="drop-icon">📁</span>
|
||||
<p>{{ selectedFile ? selectedFile.name : 'Drag & drop or click to browse' }}</p>
|
||||
<input #fileInput type="file" accept="image/*" hidden (change)="onFileChange($event)" />
|
||||
</div>
|
||||
|
||||
<div class="tag-input" *ngIf="selectedFile">
|
||||
<label>Tags (comma or space separated)</label>
|
||||
<input [(ngModel)]="tagInput" placeholder="cat, funny, reaction" />
|
||||
<label for="tag-input">Tags (comma or space separated)</label>
|
||||
<input id="tag-input" [(ngModel)]="tagInput" placeholder="cat, funny, reaction" />
|
||||
<div class="chips">
|
||||
<span *ngFor="let tag of parseTagInput(tagInput)" class="chip">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button [disabled]="!selectedFile || uploading" (click)="submit()">
|
||||
<button type="submit" [disabled]="!selectedFile || uploading" (click)="submit()">
|
||||
<span *ngIf="uploading" class="spinner"></span>
|
||||
{{ uploading ? 'Uploading…' : 'Upload' }}
|
||||
</button>
|
||||
|
||||
<p class="toast" *ngIf="toastMessage">{{ toastMessage }}</p>
|
||||
<!-- Success banner -->
|
||||
<div class="success-banner" *ngIf="showSuccess">
|
||||
<span class="success-icon">✔</span>
|
||||
<span>{{ uploadedFilename }} uploaded.</span>
|
||||
<span class="banner-links">
|
||||
<button type="button" class="link" (click)="resetForm()">Upload another</button>
|
||||
<a routerLink="/" class="link">View in library</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="toast" *ngIf="toastMessage && !showSuccess">{{ toastMessage }}</p>
|
||||
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.upload-page { max-width: 600px; margin: 40px auto; padding: 0 16px; }
|
||||
.drop-zone { border: 2px dashed #555; border-radius: 8px; padding: 40px; text-align: center; cursor: pointer; }
|
||||
.drop-zone.drag-over { border-color: #fff; background: #1a1a1a; }
|
||||
h1 { margin-bottom: 24px; }
|
||||
.drop-zone {
|
||||
border: 2px dashed color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
border-radius: var(--radius);
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
transition: border-color var(--transition), background var(--transition);
|
||||
}
|
||||
.drop-zone.drag-over { border-color: var(--accent); background: var(--surface-raised); }
|
||||
.drop-icon { font-size: 2rem; display: block; margin-bottom: 8px; }
|
||||
.tag-input { margin: 16px 0; }
|
||||
label { display: block; margin-bottom: 4px; font-size: 0.9rem; color: #aaa; }
|
||||
input[type=text], input:not([type]) { width: 100%; padding: 8px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 4px; }
|
||||
label { display: block; margin-bottom: 4px; font-size: 0.9rem; color: var(--text-muted); }
|
||||
input[type=text], input:not([type]) { width: 100%; padding: 8px; background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); }
|
||||
input:focus { outline: none; border-color: var(--border-focus); }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.chip { background: #333; padding: 2px 10px; border-radius: 12px; font-size: 0.85rem; }
|
||||
button { padding: 10px 24px; background: #4a9eff; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
.toast { color: #4a9eff; margin-top: 12px; }
|
||||
.error { color: #ff6b6b; margin-top: 12px; }
|
||||
.chip { background: var(--surface-raised); padding: 2px 10px; border-radius: var(--radius-chip); font-size: 0.85rem; }
|
||||
button[type="submit"] {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 24px; background: var(--accent); color: var(--accent-text);
|
||||
border: none; border-radius: var(--radius); cursor: pointer; font-weight: 600;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
button[type="submit"]:disabled { opacity: 0.5; cursor: default; color: var(--text-muted); }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--accent-text); border-top-color: transparent; border-radius: 50%; animation: spin 0.7s linear infinite; }
|
||||
.success-banner {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
margin-top: 16px; padding: 12px 16px;
|
||||
background: color-mix(in srgb, #2ecc71 12%, var(--surface));
|
||||
border: 1px solid color-mix(in srgb, #2ecc71 30%, transparent);
|
||||
border-radius: var(--radius); color: var(--text);
|
||||
}
|
||||
.success-icon { color: #2ecc71; font-size: 1.1rem; }
|
||||
.banner-links { display: flex; gap: 12px; margin-left: auto; }
|
||||
.link { color: var(--accent); cursor: pointer; text-decoration: none; font-size: 0.9rem; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
.toast { color: var(--accent); margin-top: 12px; }
|
||||
.error { color: var(--danger); margin-top: 12px; }
|
||||
`],
|
||||
})
|
||||
export class UploadComponent {
|
||||
@@ -63,6 +109,9 @@ export class UploadComponent {
|
||||
toastMessage = '';
|
||||
errorMessage = '';
|
||||
isDragOver = false;
|
||||
showSuccess = false;
|
||||
uploadedFilename = '';
|
||||
private dismissTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
private imageService: ImageService,
|
||||
@@ -101,6 +150,7 @@ export class UploadComponent {
|
||||
this.uploading = true;
|
||||
this.errorMessage = '';
|
||||
this.toastMessage = '';
|
||||
this.showSuccess = false;
|
||||
|
||||
const tags = this.parseTagInput(this.tagInput);
|
||||
this.imageService.upload(this.selectedFile, tags).subscribe({
|
||||
@@ -121,13 +171,36 @@ export class UploadComponent {
|
||||
if (res.duplicate) {
|
||||
this.toastMessage = 'Already in your library';
|
||||
} else {
|
||||
this.toastMessage = 'Image uploaded successfully!';
|
||||
this.uploadedFilename = this.selectedFile?.name ?? res.id;
|
||||
this.showSuccess = true;
|
||||
this.cdr.markForCheck();
|
||||
if (this.dismissTimer) clearTimeout(this.dismissTimer);
|
||||
this.dismissTimer = setTimeout(() => {
|
||||
this.showSuccess = false;
|
||||
this.cdr.markForCheck();
|
||||
}, 4000);
|
||||
}
|
||||
await this.router.navigate(['/images', res.id]);
|
||||
}
|
||||
|
||||
handleUploadError(err: any): void {
|
||||
const apiError = err?.error;
|
||||
this.errorMessage = apiError?.detail ?? 'Upload failed. Please try again.';
|
||||
handleUploadError(err: unknown): void {
|
||||
const httpErr = err as { status?: number; error?: { detail?: string } };
|
||||
const status = httpErr?.status;
|
||||
const detail = httpErr?.error?.detail;
|
||||
if (status === 422 && detail) {
|
||||
this.errorMessage = detail;
|
||||
} else {
|
||||
this.errorMessage = 'Upload failed. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
resetForm(): void {
|
||||
this.selectedFile = null;
|
||||
this.tagInput = '';
|
||||
this.toastMessage = '';
|
||||
this.errorMessage = '';
|
||||
this.showSuccess = false;
|
||||
if (this.dismissTimer) clearTimeout(this.dismissTimer);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
:root {
|
||||
--bg: #0f0f0f;
|
||||
--surface: #1a1a1a;
|
||||
--surface-raised: #252525;
|
||||
--border: #333;
|
||||
--border-focus: #555;
|
||||
--text: #e0e0e0;
|
||||
--text-muted: #777;
|
||||
--accent: #4a9eff;
|
||||
--accent-text: #000;
|
||||
--danger: #c0392b;
|
||||
--danger-text: #fff;
|
||||
--radius: 6px;
|
||||
--radius-chip: 12px;
|
||||
--transition: 200ms ease;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from { background-position: -200% 0; }
|
||||
to { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-raised) 50%, var(--surface) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -6,7 +35,7 @@
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #0f0f0f;
|
||||
color: #e0e0e0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user