From 7d49c12ce207da566f62da186dac8f18efd75f68 Mon Sep 17 00:00:00 2001 From: agatha Date: Sat, 9 May 2026 22:21:48 +0000 Subject: [PATCH] Feat: Add Copy URL button and reusable toast notification system Detail page now has a "Copy URL" button that copies the image's direct file URL to the clipboard. A toast service (BehaviorSubject-backed, auto-dismissing after 3s) confirms success or failure. ToastComponent is registered at the app root and available to all future features. Co-Authored-By: Claude Sonnet 4.6 --- .specify/feature.json | 2 +- CLAUDE.md | 2 +- .../checklists/requirements.md | 34 +++++++ .../contracts/toast-service.md | 50 ++++++++++ specs/016-copy-url-toast/plan.md | 74 +++++++++++++++ specs/016-copy-url-toast/quickstart.md | 33 +++++++ specs/016-copy-url-toast/research.md | 55 +++++++++++ specs/016-copy-url-toast/spec.md | 76 +++++++++++++++ specs/016-copy-url-toast/tasks.md | 94 +++++++++++++++++++ ui/src/app/app.component.spec.ts | 8 ++ ui/src/app/app.component.ts | 4 +- ui/src/app/detail/detail.component.spec.ts | 49 ++++++++++ ui/src/app/detail/detail.component.ts | 18 ++++ ui/src/app/library/library.component.ts | 2 +- ui/src/app/services/toast.service.spec.ts | 51 ++++++++++ ui/src/app/services/toast.service.ts | 25 +++++ ui/src/app/toast/toast.component.spec.ts | 49 ++++++++++ ui/src/app/toast/toast.component.ts | 44 +++++++++ 18 files changed, 666 insertions(+), 4 deletions(-) create mode 100644 specs/016-copy-url-toast/checklists/requirements.md create mode 100644 specs/016-copy-url-toast/contracts/toast-service.md create mode 100644 specs/016-copy-url-toast/plan.md create mode 100644 specs/016-copy-url-toast/quickstart.md create mode 100644 specs/016-copy-url-toast/research.md create mode 100644 specs/016-copy-url-toast/spec.md create mode 100644 specs/016-copy-url-toast/tasks.md create mode 100644 ui/src/app/services/toast.service.spec.ts create mode 100644 ui/src/app/services/toast.service.ts create mode 100644 ui/src/app/toast/toast.component.spec.ts create mode 100644 ui/src/app/toast/toast.component.ts diff --git a/.specify/feature.json b/.specify/feature.json index 006ee44..c2283e0 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1 +1 @@ -{"feature_directory":"specs/015-library-pagination"} +{"feature_directory":"specs/016-copy-url-toast"} diff --git a/CLAUDE.md b/CLAUDE.md index b763979..0e8cd4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,5 @@ For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan at -`specs/015-library-pagination/plan.md`. +`specs/016-copy-url-toast/plan.md`. diff --git a/specs/016-copy-url-toast/checklists/requirements.md b/specs/016-copy-url-toast/checklists/requirements.md new file mode 100644 index 0000000..ab7035d --- /dev/null +++ b/specs/016-copy-url-toast/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Copy URL & Toast Notifications + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-09 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [X] No implementation details (languages, frameworks, APIs) +- [X] Focused on user value and business needs +- [X] Written for non-technical stakeholders +- [X] All mandatory sections completed + +## Requirement Completeness + +- [X] No [NEEDS CLARIFICATION] markers remain +- [X] Requirements are testable and unambiguous +- [X] Success criteria are measurable +- [X] Success criteria are technology-agnostic (no implementation details) +- [X] All acceptance scenarios are defined +- [X] Edge cases are identified +- [X] Scope is clearly bounded +- [X] Dependencies and assumptions identified + +## Feature Readiness + +- [X] All functional requirements have clear acceptance criteria +- [X] User scenarios cover primary flows +- [X] Feature meets measurable outcomes defined in Success Criteria +- [X] No implementation details leak into specification + +## Notes + +- All items pass. Ready to proceed to `/speckit-plan`. diff --git a/specs/016-copy-url-toast/contracts/toast-service.md b/specs/016-copy-url-toast/contracts/toast-service.md new file mode 100644 index 0000000..898638e --- /dev/null +++ b/specs/016-copy-url-toast/contracts/toast-service.md @@ -0,0 +1,50 @@ +# Contract: ToastService + +**Location**: `ui/src/app/services/toast.service.ts` +**Provided in**: `root` (singleton) + +## Interface + +```typescript +interface Toast { + message: string; + type: 'success' | 'error'; +} + +class ToastService { + // Observable — emits a Toast when one is active, null when none. + readonly current$: Observable; + + // Show a toast. Replaces any currently-visible toast. + // duration defaults to 3000ms. + show(message: string, type?: 'success' | 'error', duration?: number): void; +} +``` + +## Behaviour + +- `show()` emits the toast immediately on `current$`. +- After `duration` ms, emits `null` to dismiss. +- Calling `show()` again before the timer expires resets the timer (new toast replaces old). +- `type` defaults to `'success'`. +- `duration` defaults to `3000`. + +## Usage Example + +```typescript +// In any component: +constructor(private toast: ToastService) {} + +async copyUrl() { + try { + await navigator.clipboard.writeText(url); + this.toast.show('URL copied!'); + } catch { + this.toast.show('Failed to copy URL', 'error'); + } +} +``` + +## Consumer: ToastComponent + +`ToastComponent` subscribes to `current$` via the `async` pipe and renders/hides based on the emitted value. It is placed once in `AppComponent` and is always present in the DOM. diff --git a/specs/016-copy-url-toast/plan.md b/specs/016-copy-url-toast/plan.md new file mode 100644 index 0000000..9abe81e --- /dev/null +++ b/specs/016-copy-url-toast/plan.md @@ -0,0 +1,74 @@ +# Implementation Plan: Copy URL & Toast Notifications + +**Branch**: `016-copy-url-toast` | **Date**: 2026-05-09 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `specs/016-copy-url-toast/spec.md` + +## Summary + +Add a "Copy URL" button to the image detail page that copies the image's direct file URL to the clipboard, with a reusable toast notification service wired to confirm success or failure. All changes are UI-only; no API changes are required. + +## Technical Context + +**Language/Version**: TypeScript (strict mode), Angular latest stable +**Primary Dependencies**: Angular (`@angular/core`, `@angular/common`), RxJS (`BehaviorSubject`), browser Clipboard API (`navigator.clipboard.writeText`) +**Storage**: N/A +**Testing**: Karma/Jasmine (`ng test`) +**Target Platform**: Browser (modern; Clipboard API requires HTTPS — already in place) +**Project Type**: Angular standalone SPA +**Performance Goals**: Copy action completes in < 100ms perceived latency; toast appears within 300ms of action +**Constraints**: TypeScript strict mode, `ChangeDetectionStrategy.OnPush` on all components, no new npm dependencies +**Scale/Scope**: Two new files (service + component), two modified files (detail + app component) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| §2.1 Strict separation of concerns | ✓ PASS | Pure UI change; no API knowledge in UI beyond what's already in `ImageRecord.file_url` | +| §2.6 No speculative abstraction | ✓ PASS | Toast service is justified: used immediately by this feature and explicitly planned for reuse (upload confirmation, delete confirmation, filter feedback). Two concrete use cases exist. | +| §5.1 Tests alongside implementation | ✓ PASS | Tests required for `ToastService` and the copy button on `DetailComponent` | +| §5.2 Test pyramid | ✓ PASS | Unit tests only (no API/DB involved); Karma/Jasmine | +| §6 Tech stack | ✓ PASS | Angular, TypeScript strict — no new dependencies | +| §7.3 Linting | ✓ PASS | `ng lint` must pass before task is done | +| §8 Scope boundaries | ✓ PASS | No multi-user, no embeds, no public sharing infrastructure — just a clipboard copy | + +**Post-design re-check**: No violations. Feature is entirely additive. + +## Project Structure + +### Documentation (this feature) + +```text +specs/016-copy-url-toast/ +├── plan.md ← this file +├── research.md ← Phase 0 output +├── quickstart.md ← Phase 1 output +├── contracts/ +│ └── toast-service.md ← Phase 1 output +└── tasks.md ← /speckit-tasks output +``` + +### Source Code + +```text +ui/src/app/ +├── app.component.ts ← modified: add to template +├── services/ +│ └── toast.service.ts ← new: singleton toast service +├── toast/ +│ └── toast.component.ts ← new: toast display component +└── detail/ + └── detail.component.ts ← modified: add Copy URL button + inject ToastService + +ui/src/app/services/ + toast.service.spec.ts ← new: unit tests for ToastService + +ui/src/app/toast/ + toast.component.spec.ts ← new: unit tests for ToastComponent + +ui/src/app/detail/ + detail.component.spec.ts ← modified: tests for copy button behaviour +``` + +**Structure Decision**: Single-project Angular SPA. Toast service lives in `services/` alongside `ImageService` and `TagService`. Toast component gets its own `toast/` directory following the existing component-per-directory convention. diff --git a/specs/016-copy-url-toast/quickstart.md b/specs/016-copy-url-toast/quickstart.md new file mode 100644 index 0000000..33c53b1 --- /dev/null +++ b/specs/016-copy-url-toast/quickstart.md @@ -0,0 +1,33 @@ +# Quickstart: Copy URL & Toast Notifications + +## Happy Path — Copy URL + +1. Open any image detail page (e.g. `http://localhost:4200/images/{id}`). +2. Confirm a "Copy URL" button is visible. +3. Click "Copy URL". +4. Confirm a success toast appears ("URL copied!" or similar) and then disappears automatically. +5. Paste into a text editor — confirm the pasted value is the full image file URL. + +## Happy Path — Toast Auto-Dismiss + +1. Click "Copy URL". +2. Confirm the toast appears. +3. Do not interact — wait ~3 seconds. +4. Confirm the toast disappears on its own. + +## Edge Case — Clipboard Unavailable + +1. In Firefox, navigate to `about:config` and set `dom.events.asyncClipboard.clipboardItem` to `false` (or test with a non-HTTPS localhost where clipboard API may be blocked). +2. Click "Copy URL". +3. Confirm an error toast appears (e.g. "Failed to copy URL") and auto-dismisses. + +## Edge Case — Rapid Clicks + +1. Click "Copy URL" three times quickly. +2. Confirm only one toast is visible at a time (new toast replaces old, no overlapping stack). + +## Regression — Other Pages + +1. Navigate to the library (`/`), upload page (`/upload`), tags page (`/tags`). +2. Confirm no toast or copy button is visible on these pages. +3. Confirm existing functionality is unaffected. diff --git a/specs/016-copy-url-toast/research.md b/specs/016-copy-url-toast/research.md new file mode 100644 index 0000000..df45fc2 --- /dev/null +++ b/specs/016-copy-url-toast/research.md @@ -0,0 +1,55 @@ +# Research: Copy URL & Toast Notifications + +## Decision 1: Toast Service Architecture + +**Decision**: `BehaviorSubject` singleton service, one active toast at a time — new toasts replace the current one. + +**Rationale**: The simplest approach that satisfies FR-007 (reusable from anywhere) and FR-008 (multiple toasts don't overlap illegibly). A queue adds complexity with no meaningful UX benefit for this app's usage pattern (copy URL, upload confirm, etc. — actions that don't overlap in practice). Replacing the current toast on rapid successive calls is acceptable and visually cleaner than a stack. The `BehaviorSubject` integrates naturally with Angular's `async` pipe and OnPush change detection. + +**Alternatives considered**: +- `Subject` (not `BehaviorSubject`): Late subscribers miss toasts that already fired. Rejected — component may subscribe after service emits if change detection is deferred. +- Toast queue (array): Adds observable complexity and UI layout decisions. Rejected — over-engineered for this use case. +- Angular CDK Overlay: Official but heavy. Pulls in CDK dependency for a feature that needs ~30 lines of code. Rejected per §2.6 (no speculative abstraction) and §6 (no new dependencies). + +--- + +## Decision 2: Clipboard API Usage + +**Decision**: `navigator.clipboard.writeText(url)` — no polyfill, no fallback to `document.execCommand`. + +**Rationale**: `execCommand('copy')` is deprecated and removed in some browsers. The Clipboard API is supported in all modern browsers (Chrome 66+, Firefox 63+, Safari 13.1+). The app already requires HTTPS in production (Let's Encrypt via cert-manager), which satisfies the Clipboard API's secure context requirement. On failure (permission denied, API unavailable), catch the rejected Promise and show an error toast. + +**Alternatives considered**: +- `execCommand('copy')` fallback: Deprecated, inconsistent, adds code complexity. The failure path (error toast) covers the rare unavailability case more cleanly. + +--- + +## Decision 3: What URL to Copy + +**Decision**: Copy `image.file_url` as-is (the direct image file URL). + +**Rationale**: `file_url` is the CDN URL in production (e.g. `https://cdn.reactbin.juggalol.com/…`) — already absolute. In development it is relative (`/api/v1/images/{id}/file`); for dev use, prepend `window.location.origin`. The direct file URL is the right thing to share for a reaction image library: it embeds inline when pasted into Discord/Slack without requiring a click-through. + +**Alternatives considered**: +- Detail page URL (`/images/{id}`): The user can already copy this from the browser address bar. The file URL is the value-add. +- Always prepend `window.location.origin`: Works for both environments, adds a guard. Included as a defensive measure for the dev case. + +--- + +## Decision 4: Toast Positioning + +**Decision**: Fixed position, bottom-center of the viewport. + +**Rationale**: Bottom-center is less intrusive than top-right for a brief confirmation toast. It doesn't overlap the image or the copy button. `pointer-events: none` ensures it never blocks interaction. + +**Alternatives considered**: +- Top-right: Common convention (Material, Bootstrap) but overlaps the header/nav area in this layout. +- Top-center: Similar issue. + +--- + +## Decision 5: OnPush compatibility + +**Decision**: `ToastComponent` uses `ChangeDetectionStrategy.OnPush` with the `async` pipe consuming `toastService.current$`. Angular's `async` pipe calls `markForCheck()` automatically when the observable emits, making it fully compatible with OnPush. + +**Rationale**: Consistent with all other components in the project. No manual `markForCheck()` calls needed in `ToastComponent`. diff --git a/specs/016-copy-url-toast/spec.md b/specs/016-copy-url-toast/spec.md new file mode 100644 index 0000000..d4c8e47 --- /dev/null +++ b/specs/016-copy-url-toast/spec.md @@ -0,0 +1,76 @@ +# Feature Specification: Copy URL & Toast Notifications + +**Feature Branch**: `016-copy-url-toast` +**Created**: 2026-05-09 +**Status**: Draft + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Copy Image URL from Detail Page (Priority: P1) + +A user viewing an image on the detail page wants to share the direct link to that image. They click a "Copy URL" button and the image's direct URL is instantly copied to their clipboard, ready to paste anywhere. + +**Why this priority**: This is the core feature and the primary user value. Everything else builds on it. + +**Independent Test**: Open any image detail page. Click the "Copy URL" button. Paste into a text editor — confirm the pasted value is the direct URL to that image. + +**Acceptance Scenarios**: + +1. **Given** a user is on an image detail page, **When** they click "Copy URL", **Then** the image's direct URL is copied to their clipboard. +2. **Given** a user clicks "Copy URL", **When** the copy succeeds, **Then** a confirmation toast appears briefly and disappears on its own. +3. **Given** a user clicks "Copy URL", **When** the clipboard is unavailable (e.g. browser denies permission), **Then** a toast appears indicating the copy failed. + +--- + +### User Story 2 - Reusable Toast Notification System (Priority: P2) + +Any part of the application can trigger a brief, non-blocking notification (toast) to confirm an action or surface an error. The toast appears, persists for a short time, then disappears automatically without user interaction. + +**Why this priority**: The toast infrastructure is needed by US1 and is designed as a foundation for future features (e.g. upload confirmation, filter saved, delete confirmed). + +**Independent Test**: Trigger a toast programmatically. Confirm it appears with the correct message, then disappears automatically after a few seconds without any user interaction. + +**Acceptance Scenarios**: + +1. **Given** a toast is triggered, **When** it appears, **Then** it displays the provided message and is visible above other content. +2. **Given** a toast is visible, **When** sufficient time passes, **Then** it disappears automatically without user interaction. +3. **Given** multiple toasts are triggered in quick succession, **When** they appear, **Then** they stack or queue without overlapping illegibly. +4. **Given** a toast is visible, **When** the user interacts with the rest of the page, **Then** the toast does not block or intercept those interactions. + +--- + +### Edge Cases + +- What happens when the clipboard API is not available or permission is denied? → Show an error toast. +- What happens if the user clicks "Copy URL" multiple times rapidly? → Each click copies and shows a toast; toasts queue or stack cleanly. +- What happens on a very long URL? → URL is copied in full; toast message is fixed (not the URL itself). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The image detail page MUST display a "Copy URL" button. +- **FR-002**: Clicking "Copy URL" MUST copy the image's direct URL to the system clipboard. +- **FR-003**: A success toast MUST appear after a successful copy, confirming the action to the user. +- **FR-004**: A failure toast MUST appear if the copy cannot be completed (e.g. clipboard permission denied). +- **FR-005**: Toasts MUST disappear automatically after a fixed duration without requiring user interaction. +- **FR-006**: Toasts MUST NOT block user interaction with the rest of the page. +- **FR-007**: The toast system MUST be reusable — any part of the application must be able to trigger a toast with a custom message. +- **FR-008**: Multiple toasts triggered in quick succession MUST display without overlapping illegibly. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A user can copy an image URL in a single click with no additional steps. +- **SC-002**: Toast confirmation appears within 300ms of the copy action completing. +- **SC-003**: Toasts disappear automatically within 5 seconds of appearing. +- **SC-004**: The toast system can be triggered from any page or component without modifying the toast component itself. + +## Assumptions + +- The image's direct URL is already available on the detail page (it is — currently displayed or derivable from the current route and API response). +- Users are on modern browsers with Clipboard API support; graceful degradation covers the failure case via an error toast. +- One toast variant is sufficient for v1: a simple text message with success/error styling. No actions, no dismiss button required. +- Toast duration of approximately 3 seconds is appropriate (standard convention). +- The detail page already exists; this feature adds to it without redesigning it. diff --git a/specs/016-copy-url-toast/tasks.md b/specs/016-copy-url-toast/tasks.md new file mode 100644 index 0000000..85e7406 --- /dev/null +++ b/specs/016-copy-url-toast/tasks.md @@ -0,0 +1,94 @@ +# Tasks: Copy URL & Toast Notifications + +**Input**: Design documents from `specs/016-copy-url-toast/` +**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, contracts/toast-service.md ✅, quickstart.md ✅ + +**Tests**: Tests accompany each implementation task per §5.1. All changes are in `ui/src/app/`. + +**Organization**: No project setup needed — Angular project exists. The toast infrastructure (US2) must be built before the copy URL feature (US1) can use it, so phases follow implementation dependency order rather than spec priority order. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to + +--- + +## Phase 1: Toast Infrastructure — User Story 2 (Foundational) + +**Goal**: Build the reusable `ToastService` and `ToastComponent` that US1 and all future features depend on. + +**Independent Test**: In the browser console, call `toastService.show('Hello!')` from any page — confirm a toast appears at the bottom of the screen for ~3 seconds then disappears. Confirm no interaction with the rest of the UI is blocked. + +- [X] T001 [US2] Write tests in `ui/src/app/services/toast.service.spec.ts` covering: (1) `show()` emits a `Toast` object on `current$` with the correct `message` and `type`; (2) after the duration elapses, `current$` emits `null`; (3) `type` defaults to `'success'` when not provided; (4) calling `show()` a second time before the first timer fires replaces the active toast. Run `ng test` and confirm new tests FAIL. + +- [X] T002 [US2] Create `ui/src/app/services/toast.service.ts`: (a) define `export interface Toast { message: string; type: 'success' | 'error'; }`; (b) `@Injectable({ providedIn: 'root' })` class with a private `BehaviorSubject(null)`; (c) expose `readonly current$: Observable` from the subject; (d) implement `show(message: string, type: 'success' | 'error' = 'success', duration = 3000): void` — emits the toast immediately, then calls `setTimeout(() => this.subject.next(null), duration)` (store the timer handle and `clearTimeout` it at the start of `show()` so rapid calls replace correctly). Run `ng test` and confirm T001 tests pass. + +- [X] T003 [P] [US2] Write tests in `ui/src/app/toast/toast.component.spec.ts` covering: (1) when `ToastService.current$` emits a `{ message: 'Done', type: 'success' }` toast, a `.toast` element is rendered containing "Done"; (2) the element has the CSS class `success`; (3) when type is `'error'`, the element has class `error`; (4) when `current$` emits `null`, no `.toast` element is present. Run `ng test` and confirm new tests FAIL. + +- [X] T004 [US2] Create `ui/src/app/toast/toast.component.ts`: (a) standalone component, `selector: 'app-toast'`, `ChangeDetectionStrategy.OnPush`, imports `[CommonModule]`; (b) inject `ToastService` as public; (c) template: `
{{ toast.message }}
`; (d) styles: `.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 10px 20px; border-radius: var(--radius); font-size: 0.9rem; pointer-events: none; z-index: 1000; white-space: nowrap; }` with `.success { background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); }` and `.error { background: var(--danger); color: var(--danger-text); }`. Run `ng test` and confirm T003 tests pass. + +- [X] T005 [US2] Register `ToastComponent` in `ui/src/app/app.component.ts`: add `ToastComponent` to the `imports` array; add `` to the template after ``; add a test to `ui/src/app/app.component.spec.ts` asserting that an `app-toast` element is present in the rendered output. Confirm all existing AppComponent tests still pass and `ng build --configuration development` succeeds. + +**Checkpoint**: Toast infrastructure complete. Any component in the app can now inject `ToastService` and call `show()`. + +--- + +## Phase 2: Copy URL Button — User Story 1 (Priority: P1) 🎯 MVP + +**Goal**: Add a "Copy URL" button to the image detail page. One click copies the direct image file URL to the clipboard and shows a confirmation toast. + +**Independent Test**: Open any image detail page. Click "Copy URL". Confirm a success toast appears. Paste into a text editor and confirm the pasted value is the full image file URL. Then simulate a clipboard failure (e.g. revoke clipboard permission) and confirm an error toast appears instead. + +- [X] T006 [US1] Write tests in `ui/src/app/detail/detail.component.spec.ts` covering: (1) a "Copy URL" button (`.copy-url-btn`) is present in the DOM when an image is loaded; (2) clicking it calls `navigator.clipboard.writeText` with the image's `file_url` when `file_url` is already absolute (starts with `http`); (3) when `file_url` is relative (starts with `/`), `writeText` is called with `window.location.origin + file_url`; (4) when `writeText` resolves, `toastService.show` is called with a success message; (5) when `writeText` rejects, `toastService.show` is called with an error message and type `'error'`. Spy on `navigator.clipboard.writeText` using `spyOn(navigator.clipboard, 'writeText')` returning `Promise.resolve()` / `Promise.reject()` as appropriate. Run `ng test` and confirm new tests FAIL. + +- [X] T007 [US1] Update `ui/src/app/detail/detail.component.ts`: (a) inject `ToastService` (add to constructor); (b) add `copyUrl(): void` method — resolves the URL as `this.image!.file_url.startsWith('http') ? this.image!.file_url : window.location.origin + this.image!.file_url`, then calls `navigator.clipboard.writeText(url).then(() => this.toastService.show('URL copied!')).catch(() => this.toastService.show('Failed to copy URL', 'error'))`; (c) add a `` to the template inside the `*ngIf="image && !loading"` block, placed below the image and above the tags section; (d) style: `padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; margin: 12px 0;` with hover `border-color: var(--border-focus)`. Run `ng test` and confirm T006 tests pass. + +**Checkpoint**: US1 complete. Detail page has a working Copy URL button with toast feedback. + +--- + +## Phase 3: Polish & Cross-Cutting Concerns + +- [X] T008 Run `ng lint` on all modified and created files in `ui/src/app/`; fix any issues. Confirm `ng test` passes with all new and existing tests green. Manually verify all `quickstart.md` scenarios in a browser: happy path copy, auto-dismiss, clipboard error, rapid clicks, regression on other pages. + +--- + +## Dependencies & Execution Order + +- T001 before T002 (write failing tests before service implementation) +- T002 before T003/T004 (service must exist for component tests to import it) +- T003 before T004 (write failing tests before component implementation) +- T004 before T005 (component must exist before registering in app) +- T005 before T006 (toast infrastructure must be complete before copy URL tests) +- T006 before T007 (write failing tests before detail component changes) +- T007 before T008 (implementation before polish) + +### Execution Order Summary + +``` +Step 1: T001 (US2: failing ToastService tests) +Step 2: T002 (US2: ToastService implementation — tests turn green) +Step 3: T003 (US2: failing ToastComponent tests) [can parallel with T002 if needed] +Step 4: T004 (US2: ToastComponent implementation — tests turn green) +Step 5: T005 (US2: wire ToastComponent into AppComponent) +Step 6: T006 (US1: failing copy URL tests) +Step 7: T007 (US1: copy URL implementation — tests turn green) +Step 8: T008 (Polish: lint + manual verification) +``` + +--- + +## Implementation Strategy + +### MVP (US1 — single story delivers full feature value) + +1. T001–T005 — toast infrastructure +2. T006–T007 — copy URL button +3. **STOP and VALIDATE**: open browser, click Copy URL, confirm toast, paste to verify URL +4. T008 — polish +5. Deploy + +### Note on Priority Ordering + +US2 (toast system) is listed as P2 in the spec because it is infrastructure rather than the end-user-visible feature. However it is a hard implementation prerequisite for US1 (P1). Phases follow implementation dependency order: US2 infrastructure is built first, US1 feature consumes it second. diff --git a/ui/src/app/app.component.spec.ts b/ui/src/app/app.component.spec.ts index 77570b5..52a7114 100644 --- a/ui/src/app/app.component.spec.ts +++ b/ui/src/app/app.component.spec.ts @@ -83,6 +83,14 @@ describe('AppComponent', () => { expect(link.getAttribute('href')).toBe('/'); }); + it('renders app-toast element', () => { + authSpy.isAuthenticated.and.returnValue(false); + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const toast = (fixture.nativeElement as HTMLElement).querySelector('app-toast'); + expect(toast).not.toBeNull(); + }); + it('header height is 48px', () => { authSpy.isAuthenticated.and.returnValue(true); const fixture = TestBed.createComponent(AppComponent); diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index 4d73515..4c1b597 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -2,17 +2,19 @@ import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router, RouterLink, RouterOutlet } from '@angular/router'; import { AuthService } from './auth/auth.service'; +import { ToastComponent } from './toast/toast.component'; @Component({ selector: 'app-root', standalone: true, - imports: [CommonModule, RouterLink, RouterOutlet], + imports: [CommonModule, RouterLink, RouterOutlet, ToastComponent], template: `
Reactbin
+ `, styles: [` :host { display: block; } diff --git a/ui/src/app/detail/detail.component.spec.ts b/ui/src/app/detail/detail.component.spec.ts index 45d4c9c..4c0a61c 100644 --- a/ui/src/app/detail/detail.component.spec.ts +++ b/ui/src/app/detail/detail.component.spec.ts @@ -5,6 +5,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { of, throwError, Subject } from 'rxjs'; import { DetailComponent } from './detail.component'; import { ImageService } from '../services/image.service'; +import { ToastService } from '../services/toast.service'; import { routes } from '../app.routes'; const MOCK_IMAGE = { @@ -13,6 +14,7 @@ const MOCK_IMAGE = { thumbnail_key: null, file_url: '/api/v1/images/img-1/file', thumbnail_url: null, created_at: '2026-01-01T00:00:00Z', tags: ['cat', 'funny'], }; +const MOCK_IMAGE_ABS = { ...MOCK_IMAGE, file_url: 'https://cdn.example.com/img-1.jpg' }; describe('DetailComponent', () => { function setup(imageId = 'img-1', imageResponse = of(MOCK_IMAGE)) { @@ -143,4 +145,51 @@ describe('DetailComponent', () => { fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event); expect(imgEl.src).toBe(before); }); + + // ---- Copy URL ---- + + it('Copy URL button is present when image is loaded', () => { + const { fixture } = setup(); + expect((fixture.nativeElement as HTMLElement).querySelector('.copy-url-btn')).not.toBeNull(); + }); + + it('copyUrl() calls writeText with file_url when it is already absolute', async () => { + const { component } = setup('img-1', of(MOCK_IMAGE_ABS)); + const toast = TestBed.inject(ToastService); + spyOn(toast, 'show'); + spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve()); + component.copyUrl(); + await Promise.resolve(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(MOCK_IMAGE_ABS.file_url); + }); + + it('copyUrl() prepends window.location.origin when file_url is relative', async () => { + const { component } = setup(); + const toast = TestBed.inject(ToastService); + spyOn(toast, 'show'); + spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve()); + component.copyUrl(); + await Promise.resolve(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(window.location.origin + MOCK_IMAGE.file_url); + }); + + it('copyUrl() calls toast.show with success message on writeText resolve', async () => { + const { component } = setup(); + const toast = TestBed.inject(ToastService); + spyOn(toast, 'show'); + spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve()); + component.copyUrl(); + await Promise.resolve(); + expect(toast.show).toHaveBeenCalledWith('URL copied!'); + }); + + it('copyUrl() calls toast.show with error message on writeText reject', async () => { + const { component } = setup(); + const toast = TestBed.inject(ToastService); + spyOn(toast, 'show'); + spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.reject(new Error('denied'))); + component.copyUrl(); + await Promise.resolve(); + expect(toast.show).toHaveBeenCalledWith('Failed to copy URL', 'error'); + }); }); diff --git a/ui/src/app/detail/detail.component.ts b/ui/src/app/detail/detail.component.ts index 25bfb92..2153361 100644 --- a/ui/src/app/detail/detail.component.ts +++ b/ui/src/app/detail/detail.component.ts @@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ImageRecord, ImageService } from '../services/image.service'; import { AuthService } from '../auth/auth.service'; +import { ToastService } from '../services/toast.service'; const PLACEHOLDER_SVG = `data:image/svg+xml,🔗`; @@ -54,6 +55,8 @@ const PLACEHOLDER_SVG = `data:image/svg+xml, + +

Tags

@@ -139,6 +142,10 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,
-

Reactbin

+

Reactbin

Browse tags diff --git a/ui/src/app/services/toast.service.spec.ts b/ui/src/app/services/toast.service.spec.ts new file mode 100644 index 0000000..b95b5c4 --- /dev/null +++ b/ui/src/app/services/toast.service.spec.ts @@ -0,0 +1,51 @@ +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { Toast, ToastService } from './toast.service'; + +describe('ToastService', () => { + let service: ToastService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ToastService); + }); + + it('show() emits a toast with correct message and type', (done) => { + service.current$.subscribe((toast) => { + if (toast) { + expect(toast.message).toBe('Hello!'); + expect(toast.type).toBe('error'); + done(); + } + }); + service.show('Hello!', 'error'); + }); + + it('type defaults to success when not provided', (done) => { + service.current$.subscribe((toast) => { + if (toast) { + expect(toast.type).toBe('success'); + done(); + } + }); + service.show('Default type'); + }); + + it('current$ emits null after the duration elapses', fakeAsync(() => { + const emitted: (string | null)[] = []; + service.current$.subscribe((t: Toast | null) => emitted.push(t ? t.message : null)); + service.show('Auto-dismiss', 'success', 500); + tick(500); + expect(emitted).toContain(null); + })); + + it('calling show() again before timer fires replaces the active toast', fakeAsync(() => { + const messages: (string | null)[] = []; + service.current$.subscribe((t: Toast | null) => messages.push(t ? t.message : null)); + service.show('First', 'success', 1000); + tick(200); + service.show('Second', 'success', 1000); + tick(0); + const nonNull = messages.filter((m) => m !== null); + expect(nonNull[nonNull.length - 1]).toBe('Second'); + })); +}); diff --git a/ui/src/app/services/toast.service.ts b/ui/src/app/services/toast.service.ts new file mode 100644 index 0000000..fc1917a --- /dev/null +++ b/ui/src/app/services/toast.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +export interface Toast { + message: string; + type: 'success' | 'error'; +} + +@Injectable({ providedIn: 'root' }) +export class ToastService { + private readonly subject = new BehaviorSubject(null); + readonly current$: Observable = this.subject.asObservable(); + private timer: ReturnType | null = null; + + show(message: string, type: 'success' | 'error' = 'success', duration = 3000): void { + if (this.timer !== null) { + clearTimeout(this.timer); + } + this.subject.next({ message, type }); + this.timer = setTimeout(() => { + this.subject.next(null); + this.timer = null; + }, duration); + } +} diff --git a/ui/src/app/toast/toast.component.spec.ts b/ui/src/app/toast/toast.component.spec.ts new file mode 100644 index 0000000..0926e4a --- /dev/null +++ b/ui/src/app/toast/toast.component.spec.ts @@ -0,0 +1,49 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { ToastComponent } from './toast.component'; +import { ToastService } from '../services/toast.service'; + +describe('ToastComponent', () => { + let toastSvc: jasmine.SpyObj; + + beforeEach(async () => { + toastSvc = jasmine.createSpyObj('ToastService', [], { current$: of(null) }); + + await TestBed.configureTestingModule({ + imports: [ToastComponent], + providers: [{ provide: ToastService, useValue: toastSvc }], + }).compileComponents(); + }); + + it('renders a .toast element with the correct message when current$ emits a toast', async () => { + Object.defineProperty(toastSvc, 'current$', { value: of({ message: 'Done', type: 'success' as const }) }); + const fixture = TestBed.createComponent(ToastComponent); + fixture.detectChanges(); + const el = (fixture.nativeElement as HTMLElement).querySelector('.toast'); + expect(el).not.toBeNull(); + expect(el?.textContent?.trim()).toBe('Done'); + }); + + it('adds the success CSS class when type is success', async () => { + Object.defineProperty(toastSvc, 'current$', { value: of({ message: 'OK', type: 'success' as const }) }); + const fixture = TestBed.createComponent(ToastComponent); + fixture.detectChanges(); + const el = (fixture.nativeElement as HTMLElement).querySelector('.toast'); + expect(el?.classList.contains('success')).toBeTrue(); + }); + + it('adds the error CSS class when type is error', async () => { + Object.defineProperty(toastSvc, 'current$', { value: of({ message: 'Fail', type: 'error' as const }) }); + const fixture = TestBed.createComponent(ToastComponent); + fixture.detectChanges(); + const el = (fixture.nativeElement as HTMLElement).querySelector('.toast'); + expect(el?.classList.contains('error')).toBeTrue(); + }); + + it('renders nothing when current$ emits null', () => { + const fixture = TestBed.createComponent(ToastComponent); + fixture.detectChanges(); + const el = (fixture.nativeElement as HTMLElement).querySelector('.toast'); + expect(el).toBeNull(); + }); +}); diff --git a/ui/src/app/toast/toast.component.ts b/ui/src/app/toast/toast.component.ts new file mode 100644 index 0000000..dba4368 --- /dev/null +++ b/ui/src/app/toast/toast.component.ts @@ -0,0 +1,44 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ToastService } from '../services/toast.service'; + +@Component({ + selector: 'app-toast', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
{{ toast.message }}
+ `, + styles: [` + .toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + padding: 10px 20px; + border-radius: var(--radius); + font-size: 0.9rem; + pointer-events: none; + z-index: 1000; + white-space: nowrap; + } + .success { + background: var(--surface-raised); + color: var(--text); + border: 1px solid var(--border); + } + .error { + background: var(--danger); + color: var(--danger-text); + } + `], +}) +export class ToastComponent { + constructor(public toastService: ToastService) {} +}