API responses now include file_url and thumbnail_url fields. When S3_PUBLIC_BASE_URL is configured, these point to the CDN domain; when unset, they fall back to the existing API proxy paths so local dev requires no additional setup. UI updated to use response URL fields directly instead of constructing proxy URLs client-side. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
226 lines
9.4 KiB
TypeScript
226 lines
9.4 KiB
TypeScript
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">🖼</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]="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>
|
||
|
||
<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;
|
||
}
|
||
}
|
||
}
|