Files
reactbin/ui/src/app/library/library.component.ts
agatha 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

282 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Component,
OnInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
import { Subject, debounceTime, distinctUntilChanged, share, skip, timer } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ImageRecord, ImageService } from '../services/image.service';
import { TagService } from '../services/tag.service';
const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="160" viewBox="0 0 200 160"><rect width="200" height="160" fill="%23252525"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="32" fill="%23555">&#x1F5BC;</text></svg>`;
@Component({
selector: 'app-library',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="library">
<header>
<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>
</div>
</header>
<div class="filter-bar">
<input
placeholder="Filter by tag…"
(input)="onTagInput($event)"
[value]="tagSearch"
/>
<div class="chips">
<span *ngFor="let tag of activeFilters" class="chip">
{{ tag }} <button (click)="removeFilter(tag)">×</button>
</span>
</div>
<ul class="suggestions" *ngIf="suggestions.length">
<li *ngFor="let s of suggestions" (click)="addFilter(s.name)" (keydown.enter)="addFilter(s.name)" tabindex="0" role="option" [attr.aria-selected]="false">{{ s.name }} ({{ s.image_count }})</li>
</ul>
</div>
<!-- Skeleton loading grid -->
<div *ngIf="showSpinner" class="grid">
<div *ngFor="let _ of skeletonItems" class="image-card skeleton card-skeleton"></div>
</div>
<!-- Error state -->
<div *ngIf="error && !showSpinner" class="error-card">
<p>Failed to load images. Please check your connection.</p>
<button class="retry-btn" (click)="load()">Retry</button>
</div>
<!-- Empty state -->
<div *ngIf="images.length === 0 && !showSpinner && !error" class="empty-state">
<span class="empty-icon">✦</span>
<p *ngIf="activeFilters.length">No images match these filters.</p>
<p *ngIf="!activeFilters.length">No images yet.</p>
<a *ngIf="!activeFilters.length" routerLink="/upload" class="upload-link">Upload your first image</a>
</div>
<!-- Image grid -->
<div *ngIf="!showSpinner && !error" class="grid">
<div
*ngFor="let img of images"
class="image-card"
role="button"
tabindex="0"
(click)="router.navigate(['/images', img.id])"
(keydown.enter)="router.navigate(['/images', img.id])"
>
<img
[src]="img.thumbnail_url ?? img.file_url"
[alt]="img.filename"
loading="lazy"
(error)="onImgError($event)"
/>
<div class="tag-row">
<span *ngFor="let tag of img.tags" class="chip small">{{ tag }}</span>
</div>
</div>
</div>
<!-- Total count — always visible when images exist -->
<p *ngIf="total > 0 && !showSpinner && !error" class="total-count">{{ total }} images</p>
<!-- Pagination controls — only when more than one page -->
<div *ngIf="totalPages > 1 && !showSpinner && !error" class="pagination-bar">
<button *ngIf="currentPage > 1" class="prev-btn" (click)="prevPage()">← Previous</button>
<span class="page-indicator">Page {{ currentPage }} of {{ totalPages }}</span>
<button *ngIf="currentPage < totalPages" class="next-btn" (click)="nextPage()">Next →</button>
</div>
</div>
`,
styles: [`
.library { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.tags-link { color: var(--text-muted); text-decoration: none; font-size: 0.9rem; transition: color var(--transition); }
.tags-link:hover { color: var(--text); }
.upload-btn { padding: 8px 20px; background: var(--accent); color: var(--accent-text); border: none; border-radius: var(--radius); cursor: pointer; font-weight: 600; }
.filter-bar { position: relative; margin-bottom: 24px; }
.filter-bar input { width: 100%; padding: 10px; background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); }
.filter-bar input:focus { outline: none; border-color: var(--border-focus); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip { background: var(--surface-raised); padding: 3px 10px; border-radius: var(--radius-chip); font-size: 0.85rem; display: flex; align-items: center; gap: 4px; }
.chip.small { font-size: 0.75rem; padding: 2px 8px; }
.chip button { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 0; font-size: 1rem; }
.suggestions { position: absolute; z-index: 10; background: var(--surface); border: 1px solid var(--border); list-style: none; width: 100%; max-height: 200px; overflow-y: auto; border-radius: 0 0 var(--radius) var(--radius); }
.suggestions li { padding: 8px 12px; cursor: pointer; }
.suggestions li:hover { background: var(--surface-raised); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
.image-card { cursor: pointer; background: var(--surface); border-radius: var(--radius); overflow: hidden; transition: transform var(--transition), box-shadow var(--transition); }
.image-card:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
.image-card img { width: 100%; height: 160px; object-fit: cover; display: block; }
.card-skeleton { height: 200px; }
.tag-row { padding: 6px; display: flex; flex-wrap: 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-icon { display: block; font-size: 2rem; margin-bottom: 12px; }
.upload-link { display: inline-block; margin-top: 16px; color: var(--accent); text-decoration: none; font-weight: 600; }
.upload-link:hover { text-decoration: underline; }
.error-card { text-align: center; padding: 40px; background: var(--surface); border-radius: var(--radius); border: 1px solid var(--border); }
.error-card p { color: var(--text-muted); margin-bottom: 16px; }
.retry-btn { padding: 8px 24px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
.retry-btn:hover { border-color: var(--border-focus); }
.total-count { text-align: center; color: var(--text-muted); font-size: 0.85rem; margin: 16px 0 8px; }
.pagination-bar { display: flex; justify-content: center; align-items: center; gap: 16px; margin: 16px 0 24px; }
.prev-btn, .next-btn { padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
.prev-btn:hover, .next-btn:hover { border-color: var(--border-focus); }
.page-indicator { color: var(--text-muted); font-size: 0.9rem; }
`],
})
export class LibraryComponent implements OnInit {
images: ImageRecord[] = [];
activeFilters: string[] = [];
tagSearch = '';
suggestions: { name: string; image_count: number }[] = [];
showSpinner = false;
error = false;
currentPage = 1;
totalPages = 1;
total = 0;
readonly skeletonItems = Array(8).fill(null);
private readonly limit = 24;
private readonly filterChange$ = new Subject<string>();
constructor(
public imageService: ImageService,
private tagService: TagService,
public router: Router,
private cdr: ChangeDetectorRef,
private route: ActivatedRoute,
) {}
ngOnInit(): void {
const tagsParam = this.route.snapshot.queryParamMap.get('tags');
if (tagsParam) {
this.activeFilters = tagsParam.split(',').map((t) => t.trim()).filter((t) => t.length > 0);
}
const pageParam = this.route.snapshot.queryParamMap.get('page');
if (pageParam) {
this.currentPage = Math.max(1, parseInt(pageParam, 10) || 1);
}
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) => {
if (q) {
this.tagService.list(q, 10).subscribe((r) => {
this.suggestions = r.items;
this.cdr.markForCheck();
});
} else {
this.suggestions = [];
this.cdr.markForCheck();
}
});
}
load(): void {
this.error = false;
const offset = (this.currentPage - 1) * this.limit;
const req$ = this.imageService.list(this.activeFilters, this.limit, offset).pipe(share());
timer(150).pipe(takeUntil(req$)).subscribe(() => {
this.showSpinner = true;
this.cdr.markForCheck();
});
req$.subscribe({
next: (res) => {
this.images = res.items;
this.total = res.total;
this.totalPages = Math.ceil(res.total / this.limit) || 1;
const clamped = Math.max(1, Math.min(this.currentPage, this.totalPages));
if (clamped !== this.currentPage) {
this.currentPage = clamped;
this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' });
}
this.showSpinner = false;
this.cdr.markForCheck();
},
error: () => {
this.showSpinner = false;
this.error = true;
this.cdr.markForCheck();
},
});
}
nextPage(): void {
if (this.currentPage < this.totalPages) {
this.currentPage++;
this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' });
this.load();
}
}
prevPage(): void {
if (this.currentPage > 1) {
this.currentPage--;
this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' });
this.load();
}
}
onTagInput(event: Event): void {
const val = (event.target as HTMLInputElement).value;
this.tagSearch = val;
this.filterChange$.next(val);
}
addFilter(tag: string): void {
if (!this.activeFilters.includes(tag)) {
this.activeFilters = [...this.activeFilters, tag];
}
this.tagSearch = '';
this.suggestions = [];
this.applyFilter(this.activeFilters);
}
removeFilter(tag: string): void {
this.activeFilters = this.activeFilters.filter((t) => t !== tag);
this.applyFilter(this.activeFilters);
}
applyFilter(tags: string[]): void {
this.activeFilters = tags;
this.currentPage = 1;
this.images = [];
this.router.navigate([], {
queryParams: { page: 1, tags: tags.length ? tags.join(',') : null },
queryParamsHandling: 'merge',
});
this.load();
}
onImgError(event: Event): void {
const img = event.target as HTMLImageElement;
if (!img.src.startsWith('data:')) {
img.src = PLACEHOLDER_SVG;
}
}
}