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>
This commit is contained in:
2026-05-09 22:21:48 +00:00
parent 443887ea93
commit 7d49c12ce2
18 changed files with 666 additions and 4 deletions

View File

@@ -83,6 +83,14 @@ describe('AppComponent', () => {
expect(link.getAttribute('href')).toBe('/');
});
it('renders app-toast element', () => {
authSpy.isAuthenticated.and.returnValue(false);
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const toast = (fixture.nativeElement as HTMLElement).querySelector('app-toast');
expect(toast).not.toBeNull();
});
it('header height is 48px', () => {
authSpy.isAuthenticated.and.returnValue(true);
const fixture = TestBed.createComponent(AppComponent);

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
template: `
<div class="library">
<header>
<h1><a class="home-link" (click)="router.navigate(['/'])">Reactbin</a></h1>
<h1><a class="home-link" routerLink="/" [queryParams]="{}">Reactbin</a></h1>
<div class="header-actions">
<a routerLink="/tags" class="tags-link">Browse tags</a>
<button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button>

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