6 Commits

Author SHA1 Message Date
b094389131 Fix: Await second microtask tick in copyUrl reject test
The .catch() handler on a rejected promise resolves on the second
microtask tick, not the first — one extra await Promise.resolve() is
needed before the assertion.

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:24:44 +00:00
21 changed files with 692 additions and 14 deletions

View File

@@ -1 +1 @@
{"feature_directory":"specs/015-library-pagination"} {"feature_directory":"specs/016-copy-url-toast"}

View File

@@ -1,5 +1,5 @@
<!-- SPECKIT START --> <!-- SPECKIT START -->
For additional context about technologies to be used, project structure, For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan at shell commands, and other important information, read the current plan at
`specs/015-library-pagination/plan.md`. `specs/016-copy-url-toast/plan.md`.
<!-- SPECKIT END --> <!-- SPECKIT END -->

View File

@@ -15,7 +15,7 @@ spec:
spec: spec:
initContainers: initContainers:
- name: migrate - name: migrate
image: git.juggalol.com/juggalol/reactbin-api:v1.2.0 image: git.juggalol.com/juggalol/reactbin-api:v1.2.1
command: ["alembic", "upgrade", "head"] command: ["alembic", "upgrade", "head"]
workingDir: /app workingDir: /app
envFrom: envFrom:
@@ -26,7 +26,7 @@ spec:
runAsUser: 1001 runAsUser: 1001
containers: containers:
- name: api - name: api
image: git.juggalol.com/juggalol/reactbin-api:v1.2.0 image: git.juggalol.com/juggalol/reactbin-api:v1.2.1
ports: ports:
- containerPort: 8000 - containerPort: 8000
envFrom: envFrom:

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
# Contract: ToastService
**Location**: `ui/src/app/services/toast.service.ts`
**Provided in**: `root` (singleton)
## Interface
```typescript
interface Toast {
message: string;
type: 'success' | 'error';
}
class ToastService {
// Observable — emits a Toast when one is active, null when none.
readonly current$: Observable<Toast | null>;
// Show a toast. Replaces any currently-visible toast.
// duration defaults to 3000ms.
show(message: string, type?: 'success' | 'error', duration?: number): void;
}
```
## Behaviour
- `show()` emits the toast immediately on `current$`.
- After `duration` ms, emits `null` to dismiss.
- Calling `show()` again before the timer expires resets the timer (new toast replaces old).
- `type` defaults to `'success'`.
- `duration` defaults to `3000`.
## Usage Example
```typescript
// In any component:
constructor(private toast: ToastService) {}
async copyUrl() {
try {
await navigator.clipboard.writeText(url);
this.toast.show('URL copied!');
} catch {
this.toast.show('Failed to copy URL', 'error');
}
}
```
## Consumer: ToastComponent
`ToastComponent` subscribes to `current$` via the `async` pipe and renders/hides based on the emitted value. It is placed once in `AppComponent` and is always present in the DOM.

View File

@@ -0,0 +1,74 @@
# Implementation Plan: Copy URL & Toast Notifications
**Branch**: `016-copy-url-toast` | **Date**: 2026-05-09 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/016-copy-url-toast/spec.md`
## Summary
Add a "Copy URL" button to the image detail page that copies the image's direct file URL to the clipboard, with a reusable toast notification service wired to confirm success or failure. All changes are UI-only; no API changes are required.
## Technical Context
**Language/Version**: TypeScript (strict mode), Angular latest stable
**Primary Dependencies**: Angular (`@angular/core`, `@angular/common`), RxJS (`BehaviorSubject`), browser Clipboard API (`navigator.clipboard.writeText`)
**Storage**: N/A
**Testing**: Karma/Jasmine (`ng test`)
**Target Platform**: Browser (modern; Clipboard API requires HTTPS — already in place)
**Project Type**: Angular standalone SPA
**Performance Goals**: Copy action completes in < 100ms perceived latency; toast appears within 300ms of action
**Constraints**: TypeScript strict mode, `ChangeDetectionStrategy.OnPush` on all components, no new npm dependencies
**Scale/Scope**: Two new files (service + component), two modified files (detail + app component)
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| §2.1 Strict separation of concerns | ✓ PASS | Pure UI change; no API knowledge in UI beyond what's already in `ImageRecord.file_url` |
| §2.6 No speculative abstraction | ✓ PASS | Toast service is justified: used immediately by this feature and explicitly planned for reuse (upload confirmation, delete confirmation, filter feedback). Two concrete use cases exist. |
| §5.1 Tests alongside implementation | ✓ PASS | Tests required for `ToastService` and the copy button on `DetailComponent` |
| §5.2 Test pyramid | ✓ PASS | Unit tests only (no API/DB involved); Karma/Jasmine |
| §6 Tech stack | ✓ PASS | Angular, TypeScript strict — no new dependencies |
| §7.3 Linting | ✓ PASS | `ng lint` must pass before task is done |
| §8 Scope boundaries | ✓ PASS | No multi-user, no embeds, no public sharing infrastructure — just a clipboard copy |
**Post-design re-check**: No violations. Feature is entirely additive.
## Project Structure
### Documentation (this feature)
```text
specs/016-copy-url-toast/
├── plan.md ← this file
├── research.md ← Phase 0 output
├── quickstart.md ← Phase 1 output
├── contracts/
│ └── toast-service.md ← Phase 1 output
└── tasks.md ← /speckit-tasks output
```
### Source Code
```text
ui/src/app/
├── app.component.ts ← modified: add <app-toast> to template
├── services/
│ └── toast.service.ts ← new: singleton toast service
├── toast/
│ └── toast.component.ts ← new: toast display component
└── detail/
└── detail.component.ts ← modified: add Copy URL button + inject ToastService
ui/src/app/services/
toast.service.spec.ts ← new: unit tests for ToastService
ui/src/app/toast/
toast.component.spec.ts ← new: unit tests for ToastComponent
ui/src/app/detail/
detail.component.spec.ts ← modified: tests for copy button behaviour
```
**Structure Decision**: Single-project Angular SPA. Toast service lives in `services/` alongside `ImageService` and `TagService`. Toast component gets its own `toast/` directory following the existing component-per-directory convention.

View File

@@ -0,0 +1,33 @@
# Quickstart: Copy URL & Toast Notifications
## Happy Path — Copy URL
1. Open any image detail page (e.g. `http://localhost:4200/images/{id}`).
2. Confirm a "Copy URL" button is visible.
3. Click "Copy URL".
4. Confirm a success toast appears ("URL copied!" or similar) and then disappears automatically.
5. Paste into a text editor — confirm the pasted value is the full image file URL.
## Happy Path — Toast Auto-Dismiss
1. Click "Copy URL".
2. Confirm the toast appears.
3. Do not interact — wait ~3 seconds.
4. Confirm the toast disappears on its own.
## Edge Case — Clipboard Unavailable
1. In Firefox, navigate to `about:config` and set `dom.events.asyncClipboard.clipboardItem` to `false` (or test with a non-HTTPS localhost where clipboard API may be blocked).
2. Click "Copy URL".
3. Confirm an error toast appears (e.g. "Failed to copy URL") and auto-dismisses.
## Edge Case — Rapid Clicks
1. Click "Copy URL" three times quickly.
2. Confirm only one toast is visible at a time (new toast replaces old, no overlapping stack).
## Regression — Other Pages
1. Navigate to the library (`/`), upload page (`/upload`), tags page (`/tags`).
2. Confirm no toast or copy button is visible on these pages.
3. Confirm existing functionality is unaffected.

View File

@@ -0,0 +1,55 @@
# Research: Copy URL & Toast Notifications
## Decision 1: Toast Service Architecture
**Decision**: `BehaviorSubject<Toast | null>` singleton service, one active toast at a time — new toasts replace the current one.
**Rationale**: The simplest approach that satisfies FR-007 (reusable from anywhere) and FR-008 (multiple toasts don't overlap illegibly). A queue adds complexity with no meaningful UX benefit for this app's usage pattern (copy URL, upload confirm, etc. — actions that don't overlap in practice). Replacing the current toast on rapid successive calls is acceptable and visually cleaner than a stack. The `BehaviorSubject` integrates naturally with Angular's `async` pipe and OnPush change detection.
**Alternatives considered**:
- `Subject` (not `BehaviorSubject`): Late subscribers miss toasts that already fired. Rejected — component may subscribe after service emits if change detection is deferred.
- Toast queue (array): Adds observable complexity and UI layout decisions. Rejected — over-engineered for this use case.
- Angular CDK Overlay: Official but heavy. Pulls in CDK dependency for a feature that needs ~30 lines of code. Rejected per §2.6 (no speculative abstraction) and §6 (no new dependencies).
---
## Decision 2: Clipboard API Usage
**Decision**: `navigator.clipboard.writeText(url)` — no polyfill, no fallback to `document.execCommand`.
**Rationale**: `execCommand('copy')` is deprecated and removed in some browsers. The Clipboard API is supported in all modern browsers (Chrome 66+, Firefox 63+, Safari 13.1+). The app already requires HTTPS in production (Let's Encrypt via cert-manager), which satisfies the Clipboard API's secure context requirement. On failure (permission denied, API unavailable), catch the rejected Promise and show an error toast.
**Alternatives considered**:
- `execCommand('copy')` fallback: Deprecated, inconsistent, adds code complexity. The failure path (error toast) covers the rare unavailability case more cleanly.
---
## Decision 3: What URL to Copy
**Decision**: Copy `image.file_url` as-is (the direct image file URL).
**Rationale**: `file_url` is the CDN URL in production (e.g. `https://cdn.reactbin.juggalol.com/…`) — already absolute. In development it is relative (`/api/v1/images/{id}/file`); for dev use, prepend `window.location.origin`. The direct file URL is the right thing to share for a reaction image library: it embeds inline when pasted into Discord/Slack without requiring a click-through.
**Alternatives considered**:
- Detail page URL (`/images/{id}`): The user can already copy this from the browser address bar. The file URL is the value-add.
- Always prepend `window.location.origin`: Works for both environments, adds a guard. Included as a defensive measure for the dev case.
---
## Decision 4: Toast Positioning
**Decision**: Fixed position, bottom-center of the viewport.
**Rationale**: Bottom-center is less intrusive than top-right for a brief confirmation toast. It doesn't overlap the image or the copy button. `pointer-events: none` ensures it never blocks interaction.
**Alternatives considered**:
- Top-right: Common convention (Material, Bootstrap) but overlaps the header/nav area in this layout.
- Top-center: Similar issue.
---
## Decision 5: OnPush compatibility
**Decision**: `ToastComponent` uses `ChangeDetectionStrategy.OnPush` with the `async` pipe consuming `toastService.current$`. Angular's `async` pipe calls `markForCheck()` automatically when the observable emits, making it fully compatible with OnPush.
**Rationale**: Consistent with all other components in the project. No manual `markForCheck()` calls needed in `ToastComponent`.

View File

@@ -0,0 +1,76 @@
# Feature Specification: Copy URL & Toast Notifications
**Feature Branch**: `016-copy-url-toast`
**Created**: 2026-05-09
**Status**: Draft
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Copy Image URL from Detail Page (Priority: P1)
A user viewing an image on the detail page wants to share the direct link to that image. They click a "Copy URL" button and the image's direct URL is instantly copied to their clipboard, ready to paste anywhere.
**Why this priority**: This is the core feature and the primary user value. Everything else builds on it.
**Independent Test**: Open any image detail page. Click the "Copy URL" button. Paste into a text editor — confirm the pasted value is the direct URL to that image.
**Acceptance Scenarios**:
1. **Given** a user is on an image detail page, **When** they click "Copy URL", **Then** the image's direct URL is copied to their clipboard.
2. **Given** a user clicks "Copy URL", **When** the copy succeeds, **Then** a confirmation toast appears briefly and disappears on its own.
3. **Given** a user clicks "Copy URL", **When** the clipboard is unavailable (e.g. browser denies permission), **Then** a toast appears indicating the copy failed.
---
### User Story 2 - Reusable Toast Notification System (Priority: P2)
Any part of the application can trigger a brief, non-blocking notification (toast) to confirm an action or surface an error. The toast appears, persists for a short time, then disappears automatically without user interaction.
**Why this priority**: The toast infrastructure is needed by US1 and is designed as a foundation for future features (e.g. upload confirmation, filter saved, delete confirmed).
**Independent Test**: Trigger a toast programmatically. Confirm it appears with the correct message, then disappears automatically after a few seconds without any user interaction.
**Acceptance Scenarios**:
1. **Given** a toast is triggered, **When** it appears, **Then** it displays the provided message and is visible above other content.
2. **Given** a toast is visible, **When** sufficient time passes, **Then** it disappears automatically without user interaction.
3. **Given** multiple toasts are triggered in quick succession, **When** they appear, **Then** they stack or queue without overlapping illegibly.
4. **Given** a toast is visible, **When** the user interacts with the rest of the page, **Then** the toast does not block or intercept those interactions.
---
### Edge Cases
- What happens when the clipboard API is not available or permission is denied? → Show an error toast.
- What happens if the user clicks "Copy URL" multiple times rapidly? → Each click copies and shows a toast; toasts queue or stack cleanly.
- What happens on a very long URL? → URL is copied in full; toast message is fixed (not the URL itself).
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The image detail page MUST display a "Copy URL" button.
- **FR-002**: Clicking "Copy URL" MUST copy the image's direct URL to the system clipboard.
- **FR-003**: A success toast MUST appear after a successful copy, confirming the action to the user.
- **FR-004**: A failure toast MUST appear if the copy cannot be completed (e.g. clipboard permission denied).
- **FR-005**: Toasts MUST disappear automatically after a fixed duration without requiring user interaction.
- **FR-006**: Toasts MUST NOT block user interaction with the rest of the page.
- **FR-007**: The toast system MUST be reusable — any part of the application must be able to trigger a toast with a custom message.
- **FR-008**: Multiple toasts triggered in quick succession MUST display without overlapping illegibly.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A user can copy an image URL in a single click with no additional steps.
- **SC-002**: Toast confirmation appears within 300ms of the copy action completing.
- **SC-003**: Toasts disappear automatically within 5 seconds of appearing.
- **SC-004**: The toast system can be triggered from any page or component without modifying the toast component itself.
## Assumptions
- The image's direct URL is already available on the detail page (it is — currently displayed or derivable from the current route and API response).
- Users are on modern browsers with Clipboard API support; graceful degradation covers the failure case via an error toast.
- One toast variant is sufficient for v1: a simple text message with success/error styling. No actions, no dismiss button required.
- Toast duration of approximately 3 seconds is appropriate (standard convention).
- The detail page already exists; this feature adds to it without redesigning it.

View File

@@ -0,0 +1,94 @@
# Tasks: Copy URL & Toast Notifications
**Input**: Design documents from `specs/016-copy-url-toast/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, contracts/toast-service.md ✅, quickstart.md ✅
**Tests**: Tests accompany each implementation task per §5.1. All changes are in `ui/src/app/`.
**Organization**: No project setup needed — Angular project exists. The toast infrastructure (US2) must be built before the copy URL feature (US1) can use it, so phases follow implementation dependency order rather than spec priority order.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to
---
## Phase 1: Toast Infrastructure — User Story 2 (Foundational)
**Goal**: Build the reusable `ToastService` and `ToastComponent` that US1 and all future features depend on.
**Independent Test**: In the browser console, call `toastService.show('Hello!')` from any page — confirm a toast appears at the bottom of the screen for ~3 seconds then disappears. Confirm no interaction with the rest of the UI is blocked.
- [X] T001 [US2] Write tests in `ui/src/app/services/toast.service.spec.ts` covering: (1) `show()` emits a `Toast` object on `current$` with the correct `message` and `type`; (2) after the duration elapses, `current$` emits `null`; (3) `type` defaults to `'success'` when not provided; (4) calling `show()` a second time before the first timer fires replaces the active toast. Run `ng test` and confirm new tests FAIL.
- [X] T002 [US2] Create `ui/src/app/services/toast.service.ts`: (a) define `export interface Toast { message: string; type: 'success' | 'error'; }`; (b) `@Injectable({ providedIn: 'root' })` class with a private `BehaviorSubject<Toast | null>(null)`; (c) expose `readonly current$: Observable<Toast | null>` from the subject; (d) implement `show(message: string, type: 'success' | 'error' = 'success', duration = 3000): void` — emits the toast immediately, then calls `setTimeout(() => this.subject.next(null), duration)` (store the timer handle and `clearTimeout` it at the start of `show()` so rapid calls replace correctly). Run `ng test` and confirm T001 tests pass.
- [X] T003 [P] [US2] Write tests in `ui/src/app/toast/toast.component.spec.ts` covering: (1) when `ToastService.current$` emits a `{ message: 'Done', type: 'success' }` toast, a `.toast` element is rendered containing "Done"; (2) the element has the CSS class `success`; (3) when type is `'error'`, the element has class `error`; (4) when `current$` emits `null`, no `.toast` element is present. Run `ng test` and confirm new tests FAIL.
- [X] T004 [US2] Create `ui/src/app/toast/toast.component.ts`: (a) standalone component, `selector: 'app-toast'`, `ChangeDetectionStrategy.OnPush`, imports `[CommonModule]`; (b) inject `ToastService` as public; (c) template: `<div *ngIf="toastService.current$ | async as toast" class="toast" [class.success]="toast.type === 'success'" [class.error]="toast.type === 'error'">{{ toast.message }}</div>`; (d) styles: `.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 10px 20px; border-radius: var(--radius); font-size: 0.9rem; pointer-events: none; z-index: 1000; white-space: nowrap; }` with `.success { background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); }` and `.error { background: var(--danger); color: var(--danger-text); }`. Run `ng test` and confirm T003 tests pass.
- [X] T005 [US2] Register `ToastComponent` in `ui/src/app/app.component.ts`: add `ToastComponent` to the `imports` array; add `<app-toast></app-toast>` to the template after `<router-outlet />`; add a test to `ui/src/app/app.component.spec.ts` asserting that an `app-toast` element is present in the rendered output. Confirm all existing AppComponent tests still pass and `ng build --configuration development` succeeds.
**Checkpoint**: Toast infrastructure complete. Any component in the app can now inject `ToastService` and call `show()`.
---
## Phase 2: Copy URL Button — User Story 1 (Priority: P1) 🎯 MVP
**Goal**: Add a "Copy URL" button to the image detail page. One click copies the direct image file URL to the clipboard and shows a confirmation toast.
**Independent Test**: Open any image detail page. Click "Copy URL". Confirm a success toast appears. Paste into a text editor and confirm the pasted value is the full image file URL. Then simulate a clipboard failure (e.g. revoke clipboard permission) and confirm an error toast appears instead.
- [X] T006 [US1] Write tests in `ui/src/app/detail/detail.component.spec.ts` covering: (1) a "Copy URL" button (`.copy-url-btn`) is present in the DOM when an image is loaded; (2) clicking it calls `navigator.clipboard.writeText` with the image's `file_url` when `file_url` is already absolute (starts with `http`); (3) when `file_url` is relative (starts with `/`), `writeText` is called with `window.location.origin + file_url`; (4) when `writeText` resolves, `toastService.show` is called with a success message; (5) when `writeText` rejects, `toastService.show` is called with an error message and type `'error'`. Spy on `navigator.clipboard.writeText` using `spyOn(navigator.clipboard, 'writeText')` returning `Promise.resolve()` / `Promise.reject()` as appropriate. Run `ng test` and confirm new tests FAIL.
- [X] T007 [US1] Update `ui/src/app/detail/detail.component.ts`: (a) inject `ToastService` (add to constructor); (b) add `copyUrl(): void` method — resolves the URL as `this.image!.file_url.startsWith('http') ? this.image!.file_url : window.location.origin + this.image!.file_url`, then calls `navigator.clipboard.writeText(url).then(() => this.toastService.show('URL copied!')).catch(() => this.toastService.show('Failed to copy URL', 'error'))`; (c) add a `<button class="copy-url-btn" (click)="copyUrl()">Copy URL</button>` to the template inside the `*ngIf="image && !loading"` block, placed below the image and above the tags section; (d) style: `padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; margin: 12px 0;` with hover `border-color: var(--border-focus)`. Run `ng test` and confirm T006 tests pass.
**Checkpoint**: US1 complete. Detail page has a working Copy URL button with toast feedback.
---
## Phase 3: Polish & Cross-Cutting Concerns
- [X] T008 Run `ng lint` on all modified and created files in `ui/src/app/`; fix any issues. Confirm `ng test` passes with all new and existing tests green. Manually verify all `quickstart.md` scenarios in a browser: happy path copy, auto-dismiss, clipboard error, rapid clicks, regression on other pages.
---
## Dependencies & Execution Order
- T001 before T002 (write failing tests before service implementation)
- T002 before T003/T004 (service must exist for component tests to import it)
- T003 before T004 (write failing tests before component implementation)
- T004 before T005 (component must exist before registering in app)
- T005 before T006 (toast infrastructure must be complete before copy URL tests)
- T006 before T007 (write failing tests before detail component changes)
- T007 before T008 (implementation before polish)
### Execution Order Summary
```
Step 1: T001 (US2: failing ToastService tests)
Step 2: T002 (US2: ToastService implementation — tests turn green)
Step 3: T003 (US2: failing ToastComponent tests) [can parallel with T002 if needed]
Step 4: T004 (US2: ToastComponent implementation — tests turn green)
Step 5: T005 (US2: wire ToastComponent into AppComponent)
Step 6: T006 (US1: failing copy URL tests)
Step 7: T007 (US1: copy URL implementation — tests turn green)
Step 8: T008 (Polish: lint + manual verification)
```
---
## Implementation Strategy
### MVP (US1 — single story delivers full feature value)
1. T001T005 — toast infrastructure
2. T006T007 — copy URL button
3. **STOP and VALIDATE**: open browser, click Copy URL, confirm toast, paste to verify URL
4. T008 — polish
5. Deploy
### Note on Priority Ordering
US2 (toast system) is listed as P2 in the spec because it is infrastructure rather than the end-user-visible feature. However it is a hard implementation prerequisite for US1 (P1). Phases follow implementation dependency order: US2 infrastructure is built first, US1 feature consumes it second.

View File

@@ -83,6 +83,14 @@ describe('AppComponent', () => {
expect(link.getAttribute('href')).toBe('/'); 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', () => { it('header height is 48px', () => {
authSpy.isAuthenticated.and.returnValue(true); authSpy.isAuthenticated.and.returnValue(true);
const fixture = TestBed.createComponent(AppComponent); const fixture = TestBed.createComponent(AppComponent);

View File

@@ -2,17 +2,19 @@ import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Router, RouterLink, RouterOutlet } from '@angular/router'; import { Router, RouterLink, RouterOutlet } from '@angular/router';
import { AuthService } from './auth/auth.service'; import { AuthService } from './auth/auth.service';
import { ToastComponent } from './toast/toast.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, RouterOutlet], imports: [CommonModule, RouterLink, RouterOutlet, ToastComponent],
template: ` template: `
<header class="app-header"> <header class="app-header">
<a routerLink="/" class="app-name">Reactbin</a> <a routerLink="/" class="app-name">Reactbin</a>
<button *ngIf="auth.isAuthenticated()" class="logout-btn" (click)="onLogout()">Sign out</button> <button *ngIf="auth.isAuthenticated()" class="logout-btn" (click)="onLogout()">Sign out</button>
</header> </header>
<router-outlet /> <router-outlet />
<app-toast></app-toast>
`, `,
styles: [` styles: [`
:host { display: block; } :host { display: block; }

View File

@@ -5,6 +5,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing';
import { of, throwError, Subject } from 'rxjs'; import { of, throwError, Subject } from 'rxjs';
import { DetailComponent } from './detail.component'; import { DetailComponent } from './detail.component';
import { ImageService } from '../services/image.service'; import { ImageService } from '../services/image.service';
import { ToastService } from '../services/toast.service';
import { routes } from '../app.routes'; import { routes } from '../app.routes';
const MOCK_IMAGE = { const MOCK_IMAGE = {
@@ -13,6 +14,7 @@ const MOCK_IMAGE = {
thumbnail_key: null, file_url: '/api/v1/images/img-1/file', thumbnail_url: null, thumbnail_key: null, file_url: '/api/v1/images/img-1/file', thumbnail_url: null,
created_at: '2026-01-01T00:00:00Z', tags: ['cat', 'funny'], 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', () => { describe('DetailComponent', () => {
function setup(imageId = 'img-1', imageResponse = of(MOCK_IMAGE)) { function setup(imageId = 'img-1', imageResponse = of(MOCK_IMAGE)) {
@@ -143,4 +145,52 @@ describe('DetailComponent', () => {
fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event); fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event);
expect(imgEl.src).toBe(before); expect(imgEl.src).toBe(before);
}); });
// ---- Copy URL ----
it('Copy URL button is present when image is loaded', () => {
const { fixture } = setup();
expect((fixture.nativeElement as HTMLElement).querySelector('.copy-url-btn')).not.toBeNull();
});
it('copyUrl() calls writeText with file_url when it is already absolute', async () => {
const { component } = setup('img-1', of(MOCK_IMAGE_ABS));
const toast = TestBed.inject(ToastService);
spyOn(toast, 'show');
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
component.copyUrl();
await Promise.resolve();
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(MOCK_IMAGE_ABS.file_url);
});
it('copyUrl() prepends window.location.origin when file_url is relative', async () => {
const { component } = setup();
const toast = TestBed.inject(ToastService);
spyOn(toast, 'show');
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
component.copyUrl();
await Promise.resolve();
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(window.location.origin + MOCK_IMAGE.file_url);
});
it('copyUrl() calls toast.show with success message on writeText resolve', async () => {
const { component } = setup();
const toast = TestBed.inject(ToastService);
spyOn(toast, 'show');
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
component.copyUrl();
await Promise.resolve();
expect(toast.show).toHaveBeenCalledWith('URL copied!');
});
it('copyUrl() calls toast.show with error message on writeText reject', async () => {
const { component } = setup();
const toast = TestBed.inject(ToastService);
spyOn(toast, 'show');
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.reject(new Error('denied')));
component.copyUrl();
await Promise.resolve();
await Promise.resolve();
expect(toast.show).toHaveBeenCalledWith('Failed to copy URL', 'error');
});
}); });

View File

@@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ImageRecord, ImageService } from '../services/image.service'; import { ImageRecord, ImageService } from '../services/image.service';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { ToastService } from '../services/toast.service';
const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500"><rect width="800" height="500" fill="%23111"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="48" fill="%23444">&#x1F517;</text></svg>`; const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500"><rect width="800" height="500" fill="%23111"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="48" fill="%23444">&#x1F517;</text></svg>`;
@@ -54,6 +55,8 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
(error)="onImgError($event)" (error)="onImgError($event)"
/> />
<button class="copy-url-btn" (click)="copyUrl()">Copy URL</button>
<section class="tags-section"> <section class="tags-section">
<h3>Tags</h3> <h3>Tags</h3>
<div class="chips"> <div class="chips">
@@ -139,6 +142,10 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
.add-tag input:focus { outline: none; border-color: var(--border-focus); } .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-btn { padding: 10px 24px; background: var(--danger); color: var(--danger-text); border: none; border-radius: var(--radius); cursor: pointer; margin-left: auto; }
/* Copy URL */
.copy-url-btn { padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; margin: 12px 0; transition: border-color var(--transition); }
.copy-url-btn:hover { border-color: var(--border-focus); }
/* Delete dialog */ /* 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-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; }
.dialog { background: var(--surface); padding: 32px; border-radius: 10px; text-align: center; } .dialog { background: var(--surface); padding: 32px; border-radius: 10px; text-align: center; }
@@ -163,6 +170,7 @@ export class DetailComponent implements OnInit {
private route: ActivatedRoute, private route: ActivatedRoute,
public router: Router, public router: Router,
private cdr: ChangeDetectorRef, private cdr: ChangeDetectorRef,
private toastService: ToastService,
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -235,6 +243,16 @@ export class DetailComponent implements OnInit {
this.cdr.markForCheck(); this.cdr.markForCheck();
} }
copyUrl(): void {
if (!this.image) return;
const url = this.image.file_url.startsWith('http')
? this.image.file_url
: window.location.origin + this.image.file_url;
navigator.clipboard.writeText(url)
.then(() => this.toastService.show('URL copied!'))
.catch(() => this.toastService.show('Failed to copy URL', 'error'));
}
goBack(): void { this.router.navigate(['/']); } goBack(): void { this.router.navigate(['/']); }
onImgError(event: Event): void { onImgError(event: Event): void {

View File

@@ -8,12 +8,10 @@ import { ImageService } from '../services/image.service';
import { routes } from '../app.routes'; import { routes } from '../app.routes';
function makeActivatedRoute(queryParams: Record<string, string> = {}) { function makeActivatedRoute(queryParams: Record<string, string> = {}) {
const paramMap = { get: (key: string) => queryParams[key] ?? null };
return { return {
snapshot: { snapshot: { queryParamMap: paramMap },
queryParamMap: { queryParamMap: of(paramMap),
get: (key: string) => queryParams[key] ?? null,
},
},
}; };
} }

View File

@@ -6,7 +6,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Router, RouterLink, ActivatedRoute } from '@angular/router'; import { Router, RouterLink, ActivatedRoute } from '@angular/router';
import { Subject, debounceTime, distinctUntilChanged, share, timer } from 'rxjs'; import { Subject, debounceTime, distinctUntilChanged, share, skip, timer } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { ImageRecord, ImageService } from '../services/image.service'; import { ImageRecord, ImageService } from '../services/image.service';
import { TagService } from '../services/tag.service'; import { TagService } from '../services/tag.service';
@@ -21,7 +21,7 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
template: ` template: `
<div class="library"> <div class="library">
<header> <header>
<h1>Reactbin</h1> <h1><a class="home-link" routerLink="/" [queryParams]="{}">Reactbin</a></h1>
<div class="header-actions"> <div class="header-actions">
<a routerLink="/tags" class="tags-link">Browse tags</a> <a routerLink="/tags" class="tags-link">Browse tags</a>
<button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button> <button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button>
@@ -118,7 +118,9 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
.image-card:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0,0,0,0.4); } .image-card: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; } .image-card img { width: 100%; height: 160px; object-fit: cover; display: block; }
.card-skeleton { height: 200px; } .card-skeleton { height: 200px; }
.tag-row { padding: 6px; display: flex; flex-wrap: wrap; gap: 4px; } .tag-row { padding: 6px; display: flex; flex-wrap: nowrap; gap: 4px; overflow: hidden; position: relative; }
.tag-row::after { content: ''; position: absolute; right: 0; top: 0; bottom: 0; width: 2rem; background: linear-gradient(to right, transparent, var(--surface)); pointer-events: none; }
.home-link { color: inherit; text-decoration: none; cursor: pointer; }
.empty-state { text-align: center; padding: 60px 0; color: var(--text-muted); } .empty-state { text-align: center; padding: 60px 0; color: var(--text-muted); }
.empty-icon { display: block; font-size: 2rem; margin-bottom: 12px; } .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 { display: inline-block; margin-top: 16px; color: var(--accent); text-decoration: none; font-weight: 600; }
@@ -166,6 +168,21 @@ export class LibraryComponent implements OnInit {
this.currentPage = Math.max(1, parseInt(pageParam, 10) || 1); this.currentPage = Math.max(1, parseInt(pageParam, 10) || 1);
} }
this.load(); this.load();
this.route.queryParamMap.pipe(skip(1)).subscribe((params) => {
const newPage = params.get('page') ? Math.max(1, parseInt(params.get('page')!, 10) || 1) : 1;
const newTagsParam = params.get('tags');
const newTags = newTagsParam
? newTagsParam.split(',').map((t) => t.trim()).filter((t) => t.length > 0)
: [];
const pageChanged = newPage !== this.currentPage;
const tagsChanged = JSON.stringify(newTags) !== JSON.stringify(this.activeFilters);
if (pageChanged || tagsChanged) {
this.currentPage = newPage;
this.activeFilters = newTags;
this.images = [];
this.load();
}
});
this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => { this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => {
if (q) { if (q) {
this.tagService.list(q, 10).subscribe((r) => { this.tagService.list(q, 10).subscribe((r) => {

View File

@@ -0,0 +1,51 @@
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Toast, ToastService } from './toast.service';
describe('ToastService', () => {
let service: ToastService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ToastService);
});
it('show() emits a toast with correct message and type', (done) => {
service.current$.subscribe((toast) => {
if (toast) {
expect(toast.message).toBe('Hello!');
expect(toast.type).toBe('error');
done();
}
});
service.show('Hello!', 'error');
});
it('type defaults to success when not provided', (done) => {
service.current$.subscribe((toast) => {
if (toast) {
expect(toast.type).toBe('success');
done();
}
});
service.show('Default type');
});
it('current$ emits null after the duration elapses', fakeAsync(() => {
const emitted: (string | null)[] = [];
service.current$.subscribe((t: Toast | null) => emitted.push(t ? t.message : null));
service.show('Auto-dismiss', 'success', 500);
tick(500);
expect(emitted).toContain(null);
}));
it('calling show() again before timer fires replaces the active toast', fakeAsync(() => {
const messages: (string | null)[] = [];
service.current$.subscribe((t: Toast | null) => messages.push(t ? t.message : null));
service.show('First', 'success', 1000);
tick(200);
service.show('Second', 'success', 1000);
tick(0);
const nonNull = messages.filter((m) => m !== null);
expect(nonNull[nonNull.length - 1]).toBe('Second');
}));
});

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
export interface Toast {
message: string;
type: 'success' | 'error';
}
@Injectable({ providedIn: 'root' })
export class ToastService {
private readonly subject = new BehaviorSubject<Toast | null>(null);
readonly current$: Observable<Toast | null> = this.subject.asObservable();
private timer: ReturnType<typeof setTimeout> | null = null;
show(message: string, type: 'success' | 'error' = 'success', duration = 3000): void {
if (this.timer !== null) {
clearTimeout(this.timer);
}
this.subject.next({ message, type });
this.timer = setTimeout(() => {
this.subject.next(null);
this.timer = null;
}, duration);
}
}

View File

@@ -0,0 +1,49 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { ToastComponent } from './toast.component';
import { ToastService } from '../services/toast.service';
describe('ToastComponent', () => {
let toastSvc: jasmine.SpyObj<ToastService>;
beforeEach(async () => {
toastSvc = jasmine.createSpyObj('ToastService', [], { current$: of(null) });
await TestBed.configureTestingModule({
imports: [ToastComponent],
providers: [{ provide: ToastService, useValue: toastSvc }],
}).compileComponents();
});
it('renders a .toast element with the correct message when current$ emits a toast', async () => {
Object.defineProperty(toastSvc, 'current$', { value: of({ message: 'Done', type: 'success' as const }) });
const fixture = TestBed.createComponent(ToastComponent);
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector('.toast');
expect(el).not.toBeNull();
expect(el?.textContent?.trim()).toBe('Done');
});
it('adds the success CSS class when type is success', async () => {
Object.defineProperty(toastSvc, 'current$', { value: of({ message: 'OK', type: 'success' as const }) });
const fixture = TestBed.createComponent(ToastComponent);
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector('.toast');
expect(el?.classList.contains('success')).toBeTrue();
});
it('adds the error CSS class when type is error', async () => {
Object.defineProperty(toastSvc, 'current$', { value: of({ message: 'Fail', type: 'error' as const }) });
const fixture = TestBed.createComponent(ToastComponent);
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector('.toast');
expect(el?.classList.contains('error')).toBeTrue();
});
it('renders nothing when current$ emits null', () => {
const fixture = TestBed.createComponent(ToastComponent);
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector('.toast');
expect(el).toBeNull();
});
});

View File

@@ -0,0 +1,44 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ToastService } from '../services/toast.service';
@Component({
selector: 'app-toast',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
*ngIf="toastService.current$ | async as toast"
class="toast"
[class.success]="toast.type === 'success'"
[class.error]="toast.type === 'error'"
>{{ toast.message }}</div>
`,
styles: [`
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
padding: 10px 20px;
border-radius: var(--radius);
font-size: 0.9rem;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
}
.success {
background: var(--surface-raised);
color: var(--text);
border: 1px solid var(--border);
}
.error {
background: var(--danger);
color: var(--danger-text);
}
`],
})
export class ToastComponent {
constructor(public toastService: ToastService) {}
}