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:
@@ -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: () =>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
102
ui/src/app/tags/tags.component.spec.ts
Normal file
102
ui/src/app/tags/tags.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
96
ui/src/app/tags/tags.component.ts
Normal file
96
ui/src/app/tags/tags.component.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user