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>
This commit is contained in:
2026-05-06 18:40:06 +00:00
parent 6092a4454e
commit 355014f975
32 changed files with 908 additions and 38 deletions

View File

@@ -18,6 +18,11 @@ export const routes: Routes = [
loadComponent: () =>
import('./upload/upload.component').then((m) => m.UploadComponent),
},
{
path: 'tags',
loadComponent: () =>
import('./tags/tags.component').then((m) => m.TagsComponent),
},
{
path: 'images/:id',
loadComponent: () =>

View File

@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideRouter, ActivatedRoute } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { of } from 'rxjs';
@@ -7,6 +7,16 @@ import { LibraryComponent } from './library.component';
import { ImageService } from '../services/image.service';
import { routes } from '../app.routes';
function makeActivatedRoute(queryParams: Record<string, string> = {}) {
return {
snapshot: {
queryParamMap: {
get: (key: string) => queryParams[key] ?? null,
},
},
};
}
const EMPTY_PAGE = { items: [], total: 0, limit: 50, offset: 0 };
const ONE_IMAGE = {
items: [{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, created_at: '' }],
@@ -107,4 +117,32 @@ describe('LibraryComponent', () => {
fixture.componentInstance.onImgError(event);
expect(imgEl.src).toBe(originalSrc);
});
it('pre-populates activeFilters from ?tags= query param on init', () => {
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ tags: 'cat,funny' }) });
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
fixture.detectChanges();
expect(fixture.componentInstance.activeFilters).toEqual(['cat', 'funny']);
expect(listSpy).toHaveBeenCalledWith(['cat', 'funny'], jasmine.any(Number), jasmine.any(Number));
});
it('does not set activeFilters when no ?tags= param present', () => {
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute() });
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
fixture.detectChanges();
expect(fixture.componentInstance.activeFilters).toEqual([]);
});
it('header contains a link to /tags', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
fixture.detectChanges();
const link = (fixture.nativeElement as HTMLElement).querySelector('a[href="/tags"]');
expect(link).not.toBeNull();
});
});

View File

@@ -5,7 +5,7 @@ import {
ChangeDetectorRef,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
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';
@@ -22,7 +22,10 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
<div class="library">
<header>
<h1>Reactbin</h1>
<button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button>
<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">
@@ -88,6 +91,9 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
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); }
@@ -134,9 +140,14 @@ export class LibraryComponent implements OnInit {
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) {

View File

@@ -30,4 +30,26 @@ describe('TagService', () => {
expect(req.request.params.has('q')).toBeFalse();
req.flush({ items: [], total: 0, limit: 100, offset: 0 });
});
it('should include sort param when provided', () => {
service.list('', 100, 0, 'count_desc').subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/v1/tags');
expect(req.request.params.get('sort')).toBe('count_desc');
req.flush({ items: [], total: 0, limit: 100, offset: 0 });
});
it('should include min_count param when minCount is provided', () => {
service.list('', 500, 0, 'count_desc', 1).subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/v1/tags');
expect(req.request.params.get('min_count')).toBe('1');
req.flush({ items: [], total: 0, limit: 500, offset: 0 });
});
it('should omit sort and min_count when not provided', () => {
service.list('cat').subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/v1/tags');
expect(req.request.params.has('sort')).toBeFalse();
expect(req.request.params.has('min_count')).toBeFalse();
req.flush({ items: [], total: 0, limit: 100, offset: 0 });
});
});

View File

@@ -21,11 +21,17 @@ export class TagService {
constructor(private http: HttpClient) {}
list(prefix?: string, limit = 100, offset = 0): Observable<TagListResponse> {
list(prefix = '', limit = 100, offset = 0, sort?: string, minCount?: number): Observable<TagListResponse> {
let params = new HttpParams().set('limit', limit).set('offset', offset);
if (prefix) {
params = params.set('q', prefix);
}
if (sort) {
params = params.set('sort', sort);
}
if (minCount !== undefined) {
params = params.set('min_count', minCount);
}
return this.http.get<TagListResponse>(`${this.base}/tags`, { params });
}
}

View File

@@ -0,0 +1,102 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Subject, of, throwError } from 'rxjs';
import { TagsComponent } from './tags.component';
import { TagService, TagListResponse } from '../services/tag.service';
import { routes } from '../app.routes';
const TAGS_PAGE = (items: { name: string; image_count: number }[]): TagListResponse => ({
items: items.map((t, i) => ({ id: String(i), ...t })),
total: items.length,
limit: 500,
offset: 0,
});
describe('TagsComponent', () => {
let tagSvc: jasmine.SpyObj<TagService>;
beforeEach(async () => {
tagSvc = jasmine.createSpyObj('TagService', ['list']);
await TestBed.configureTestingModule({
imports: [TagsComponent],
providers: [
{ provide: TagService, useValue: tagSvc },
provideRouter(routes),
],
}).compileComponents();
});
it('shows skeleton while loading', () => {
// list() never resolves during this test
tagSvc.list.and.returnValue(new Subject<never>().asObservable());
const fixture = TestBed.createComponent(TagsComponent);
fixture.componentInstance.showSpinner = true;
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.skeleton')).not.toBeNull();
});
it('renders tag list with name and count after load', () => {
tagSvc.list.and.returnValue(of(TAGS_PAGE([
{ name: 'cat', image_count: 5 },
{ name: 'dog', image_count: 2 },
])));
const fixture = TestBed.createComponent(TagsComponent);
fixture.detectChanges();
const items = (fixture.nativeElement as HTMLElement).querySelectorAll('.tag-item');
expect(items.length).toBe(2);
expect(items[0].textContent).toContain('cat');
expect(items[0].textContent).toContain('5');
});
it('tags are ordered by count descending (service is called with count_desc)', () => {
tagSvc.list.and.returnValue(of(TAGS_PAGE([])));
const fixture = TestBed.createComponent(TagsComponent);
fixture.detectChanges();
expect(tagSvc.list).toHaveBeenCalledWith('', 500, 0, 'count_desc', 1);
});
it('shows empty state when tag list is empty', () => {
tagSvc.list.and.returnValue(of(TAGS_PAGE([])));
const fixture = TestBed.createComponent(TagsComponent);
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.empty-state')).not.toBeNull();
});
it('shows error state on fetch failure', () => {
tagSvc.list.and.returnValue(throwError(() => new Error('network')));
const fixture = TestBed.createComponent(TagsComponent);
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.error-card')).not.toBeNull();
});
it('retry button in error state calls load again', () => {
tagSvc.list.and.returnValue(throwError(() => new Error('network')));
const fixture = TestBed.createComponent(TagsComponent);
fixture.detectChanges();
spyOn(fixture.componentInstance, 'load');
const btn = (fixture.nativeElement as HTMLElement).querySelector('.error-card .retry-btn') as HTMLButtonElement;
expect(btn).not.toBeNull();
btn.click();
expect(fixture.componentInstance.load).toHaveBeenCalled();
});
it('each tag item links to /?tags=<tagname>', () => {
tagSvc.list.and.returnValue(of(TAGS_PAGE([
{ name: 'funny', image_count: 3 },
])));
const fixture = TestBed.createComponent(TagsComponent);
fixture.detectChanges();
const link = (fixture.nativeElement as HTMLElement).querySelector('.tag-item a') as HTMLAnchorElement;
expect(link).not.toBeNull();
expect(link.getAttribute('href')).toBe('/?tags=funny');
});
it('renders without requiring authentication', () => {
tagSvc.list.and.returnValue(of(TAGS_PAGE([{ name: 'test', image_count: 1 }])));
// No AuthService injected — component must not depend on it
const fixture = TestBed.createComponent(TagsComponent);
expect(() => fixture.detectChanges()).not.toThrow();
expect((fixture.nativeElement as HTMLElement).querySelector('.tag-item')).not.toBeNull();
});
});

View File

@@ -0,0 +1,96 @@
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { TagRecord, TagService } from '../services/tag.service';
@Component({
selector: 'app-tags',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="tags-page">
<header class="tags-header">
<h1>Browse Tags</h1>
<a routerLink="/" class="back-link">← Library</a>
</header>
<!-- Skeleton -->
<div *ngIf="showSpinner" class="tag-grid">
<div *ngFor="let _ of skeletonItems" class="tag-item skeleton tag-skeleton"></div>
</div>
<!-- Error -->
<div *ngIf="error && !showSpinner" class="error-card">
<p>Failed to load tags. Please check your connection.</p>
<button class="retry-btn" (click)="load()">Retry</button>
</div>
<!-- Empty -->
<div *ngIf="!showSpinner && !error && tags.length === 0" class="empty-state">
<span class="empty-icon">✦</span>
<p>No tags yet. Upload some images and add tags to get started.</p>
</div>
<!-- Tag grid -->
<div *ngIf="!showSpinner && !error && tags.length > 0" class="tag-grid">
<div *ngFor="let tag of tags" class="tag-item">
<a [routerLink]="['/']" [queryParams]="{ tags: tag.name }" class="tag-link">
<span class="tag-name">{{ tag.name }}</span>
<span class="tag-count">{{ tag.image_count }}</span>
</a>
</div>
</div>
</div>
`,
styles: [`
.tags-page { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
.tags-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.tags-header h1 { margin: 0; }
.back-link { color: var(--text-muted); text-decoration: none; font-size: 0.9rem; }
.back-link:hover { color: var(--text); }
.tag-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; }
.tag-item { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); transition: border-color var(--transition); }
.tag-item:hover { border-color: var(--border-focus); }
.tag-skeleton { height: 56px; }
.tag-link { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; text-decoration: none; color: var(--text); }
.tag-name { font-size: 0.95rem; font-weight: 500; }
.tag-count { font-size: 0.8rem; color: var(--text-muted); background: var(--surface-raised); padding: 2px 8px; border-radius: var(--radius-chip); }
.empty-state { text-align: center; padding: 60px 0; color: var(--text-muted); }
.empty-icon { display: block; font-size: 2rem; margin-bottom: 12px; }
.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); }
`],
})
export class TagsComponent implements OnInit {
tags: TagRecord[] = [];
showSpinner = false;
error = false;
readonly skeletonItems = Array(12).fill(null);
constructor(private tagService: TagService, private cdr: ChangeDetectorRef) {}
ngOnInit(): void {
this.load();
}
load(): void {
this.error = false;
this.showSpinner = true;
this.cdr.markForCheck();
this.tagService.list('', 500, 0, 'count_desc', 1).subscribe({
next: (res) => {
this.tags = res.items;
this.showSpinner = false;
this.cdr.markForCheck();
},
error: () => {
this.showSpinner = false;
this.error = true;
this.cdr.markForCheck();
},
});
}
}