Files
reactbin/ui/src/app/library/library.component.ts
agatha 8bf6ef443a [Spec Kit] Implementation progress
Implements all 88 tasks for the Reaction Image Board (specs/001-reaction-image-board):

- docker-compose.yml: postgres, minio, minio-init, api, ui services with healthchecks
- api/: FastAPI app with SQLAlchemy 2.x async, Alembic migrations, S3/MinIO storage,
  full integration + unit test suite (pytest + pytest-asyncio)
- ui/: Angular 19 standalone app (Library, Upload, Detail, NotFound components)
- .env.example: all required environment variables
- .gitignore: Python, Node, Docker, IDE, .env patterns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:13:23 +00:00

157 lines
5.7 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 } from '@angular/router';
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs';
import { ImageRecord, ImageService } from '../services/image.service';
import { TagService } from '../services/tag.service';
@Component({
selector: 'app-library',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="library">
<header>
<h1>Reactbin</h1>
<button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button>
</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)">{{ s.name }} ({{ s.image_count }})</li>
</ul>
</div>
<div *ngIf="images.length === 0 && !loading" class="empty-state">
<p>{{ activeFilters.length ? 'No images match these filters.' : 'No images yet. Upload your first!' }}</p>
</div>
<div class="grid">
<div
*ngFor="let img of images"
class="image-card"
(click)="router.navigate(['/images', img.id])"
>
<img [src]="imageService.getFileUrl(img.id)" [alt]="img.filename" loading="lazy" />
<div class="tag-row">
<span *ngFor="let tag of img.tags" class="chip small">{{ tag }}</span>
</div>
</div>
</div>
<button *ngIf="hasMore" 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; }
.upload-btn { padding: 8px 20px; background: #4a9eff; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
.filter-bar { position: relative; margin-bottom: 24px; }
.filter-bar input { width: 100%; padding: 10px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 6px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip { background: #333; padding: 3px 10px; border-radius: 12px; 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: #aaa; cursor: pointer; padding: 0; font-size: 1rem; }
.suggestions { position: absolute; z-index: 10; background: #1a1a1a; border: 1px solid #444; list-style: none; width: 100%; max-height: 200px; overflow-y: auto; border-radius: 0 0 6px 6px; }
.suggestions li { padding: 8px 12px; cursor: pointer; }
.suggestions li:hover { background: #2a2a2a; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
.image-card { cursor: pointer; background: #1a1a1a; border-radius: 8px; overflow: hidden; }
.image-card img { width: 100%; height: 160px; object-fit: cover; display: block; }
.tag-row { padding: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
.empty-state { text-align: center; padding: 60px 0; color: #666; }
.load-more { display: block; margin: 24px auto; padding: 10px 32px; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 6px; cursor: pointer; }
`],
})
export class LibraryComponent implements OnInit {
images: ImageRecord[] = [];
activeFilters: string[] = [];
tagSearch = '';
suggestions: { name: string; image_count: number }[] = [];
loading = false;
hasMore = false;
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,
) {}
ngOnInit(): void {
this.loadImages();
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();
}
});
}
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.loadImages();
}
loadImages(): void {
this.loading = true;
this.imageService.list(this.activeFilters, this.limit, this.offset).subscribe((res) => {
this.images = [...this.images, ...res.items];
this.offset += res.items.length;
this.hasMore = this.offset < res.total;
this.loading = false;
this.cdr.markForCheck();
});
}
loadMore(): void {
this.loadImages();
}
}