Files
reactbin/ui/src/app/library/library.component.ts
agatha 355014f975 Feat: Add tag browser page at /tags with count-sorted tag list and library deep-link
- Extends GET /api/v1/tags with sort=count_desc and min_count query params
- New TagsComponent at /tags (public, no auth guard) shows all tags sorted by image count
- Clicking a tag navigates to /?tags=<name> for a pre-filtered library view
- LibraryComponent reads ?tags= query param on init to support deep-linking from tag browser
- Library header gains a "Browse tags" link to /tags for discoverability
- All 15 TDD tasks complete; ruff, ng lint, and ng build clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 18:40:06 +00:00

226 lines
9.4 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, 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>Reactbin</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]="imageService.getThumbnailUrl(img.id)"
[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>
<button *ngIf="hasMore && !showSpinner && !error" class="load-more" (click)="loadMore()">Load more</button>
</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: wrap; gap: 4px; }
.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); }
.load-more { display: block; margin: 24px auto; padding: 10px 32px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; }
`],
})
export class LibraryComponent implements OnInit {
images: ImageRecord[] = [];
activeFilters: string[] = [];
tagSearch = '';
suggestions: { name: string; image_count: number }[] = [];
showSpinner = false;
error = false;
hasMore = false;
readonly skeletonItems = Array(8).fill(null);
private offset = 0;
private readonly limit = 50;
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);
}
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 req$ = this.imageService.list(this.activeFilters, this.limit, this.offset).pipe(share());
timer(150).pipe(takeUntil(req$)).subscribe(() => {
this.showSpinner = true;
this.cdr.markForCheck();
});
req$.subscribe({
next: (res) => {
this.images = [...this.images, ...res.items];
this.offset += res.items.length;
this.hasMore = this.offset < res.total;
this.showSpinner = false;
this.cdr.markForCheck();
},
error: () => {
this.showSpinner = false;
this.error = true;
this.cdr.markForCheck();
},
});
}
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.offset = 0;
this.images = [];
this.load();
}
loadMore(): void {
this.load();
}
onImgError(event: Event): void {
const img = event.target as HTMLImageElement;
if (!img.src.startsWith('data:')) {
img.src = PLACEHOLDER_SVG;
}
}
}