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

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