[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>
This commit is contained in:
25
ui/src/app/app.component.spec.ts
Normal file
25
ui/src/app/app.component.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent],
|
||||
providers: [provideRouter(routes)],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have title reactbin-ui', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('reactbin-ui');
|
||||
});
|
||||
});
|
||||
12
ui/src/app/app.component.ts
Normal file
12
ui/src/app/app.component.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet],
|
||||
template: `<router-outlet />`,
|
||||
})
|
||||
export class AppComponent {
|
||||
title = 'reactbin-ui';
|
||||
}
|
||||
13
ui/src/app/app.config.ts
Normal file
13
ui/src/app/app.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
],
|
||||
};
|
||||
24
ui/src/app/app.routes.ts
Normal file
24
ui/src/app/app.routes.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./library/library.component').then((m) => m.LibraryComponent),
|
||||
},
|
||||
{
|
||||
path: 'upload',
|
||||
loadComponent: () =>
|
||||
import('./upload/upload.component').then((m) => m.UploadComponent),
|
||||
},
|
||||
{
|
||||
path: 'images/:id',
|
||||
loadComponent: () =>
|
||||
import('./detail/detail.component').then((m) => m.DetailComponent),
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
loadComponent: () =>
|
||||
import('./not-found/not-found.component').then((m) => m.NotFoundComponent),
|
||||
},
|
||||
];
|
||||
82
ui/src/app/detail/detail.component.spec.ts
Normal file
82
ui/src/app/detail/detail.component.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { DetailComponent } from './detail.component';
|
||||
import { ImageService } from '../services/image.service';
|
||||
import { routes } from '../app.routes';
|
||||
|
||||
const MOCK_IMAGE = {
|
||||
id: 'img-1',
|
||||
hash: 'abc',
|
||||
filename: 'test.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
size_bytes: 100,
|
||||
width: 10,
|
||||
height: 10,
|
||||
storage_key: 'abc',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
tags: ['cat', 'funny'],
|
||||
};
|
||||
|
||||
describe('DetailComponent', () => {
|
||||
function setup(imageId = 'img-1') {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [DetailComponent],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter(routes),
|
||||
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => imageId } } } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(DetailComponent);
|
||||
const component = fixture.componentInstance;
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'get').and.returnValue(of(MOCK_IMAGE));
|
||||
fixture.detectChanges();
|
||||
return { fixture, component, imgSvc };
|
||||
}
|
||||
|
||||
it('should call PATCH with removed tag absent when chip × is clicked', () => {
|
||||
const { component, imgSvc } = setup();
|
||||
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] }));
|
||||
component.removeTag('cat');
|
||||
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['funny']);
|
||||
});
|
||||
|
||||
it('should call PATCH with new tag included on addTag', () => {
|
||||
const { component, imgSvc } = setup();
|
||||
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['cat', 'funny', 'new'] }));
|
||||
component.addTag('new');
|
||||
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['cat', 'funny', 'new']);
|
||||
});
|
||||
|
||||
it('should call DELETE and navigate to library on confirm delete', () => {
|
||||
const { component, imgSvc } = setup();
|
||||
const router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate');
|
||||
spyOn(imgSvc, 'delete').and.returnValue(of(undefined));
|
||||
component.confirmDelete();
|
||||
expect(imgSvc.delete).toHaveBeenCalledWith('img-1');
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||
});
|
||||
|
||||
it('should NOT call DELETE when cancel is clicked', () => {
|
||||
const { component, imgSvc } = setup();
|
||||
spyOn(imgSvc, 'delete').and.returnValue(of(undefined));
|
||||
component.showDeleteDialog = true;
|
||||
component.cancelDelete();
|
||||
expect(imgSvc.delete).not.toHaveBeenCalled();
|
||||
expect(component.showDeleteDialog).toBeFalse();
|
||||
});
|
||||
|
||||
it('back button should navigate to library', () => {
|
||||
const { component } = setup();
|
||||
const router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate');
|
||||
component.goBack();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||
});
|
||||
});
|
||||
128
ui/src/app/detail/detail.component.ts
Normal file
128
ui/src/app/detail/detail.component.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ImageRecord, ImageService } from '../services/image.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="detail-page" *ngIf="image">
|
||||
<button class="back-btn" (click)="goBack()">← Back</button>
|
||||
<h2>{{ image.filename }}</h2>
|
||||
|
||||
<img class="full-image" [src]="imageService.getFileUrl(image.id)" [alt]="image.filename" />
|
||||
|
||||
<section class="tags-section">
|
||||
<h3>Tags</h3>
|
||||
<div class="chips">
|
||||
<span *ngFor="let tag of image.tags" class="chip">
|
||||
{{ tag }} <button (click)="removeTag(tag)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="add-tag">
|
||||
<input
|
||||
[(ngModel)]="newTagInput"
|
||||
placeholder="Add tag…"
|
||||
(keydown.enter)="onEnter()"
|
||||
(blur)="onBlur()"
|
||||
/>
|
||||
</div>
|
||||
<p class="tag-error" *ngIf="tagError">{{ tagError }}</p>
|
||||
</section>
|
||||
|
||||
<button class="delete-btn" (click)="showDeleteDialog = true">Delete Image</button>
|
||||
|
||||
<div class="dialog-overlay" *ngIf="showDeleteDialog">
|
||||
<div class="dialog">
|
||||
<p>Permanently delete this image?</p>
|
||||
<button (click)="confirmDelete()">Delete</button>
|
||||
<button (click)="cancelDelete()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p *ngIf="!image && !loading" class="not-found">Image not found.</p>
|
||||
`,
|
||||
styles: [`
|
||||
.detail-page { max-width: 900px; margin: 32px auto; padding: 0 16px; }
|
||||
.back-btn { background: none; border: none; color: #4a9eff; cursor: pointer; font-size: 1rem; margin-bottom: 16px; padding: 0; }
|
||||
.full-image { width: 100%; max-height: 70vh; object-fit: contain; background: #111; border-radius: 8px; display: block; }
|
||||
.tags-section { margin-top: 24px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
||||
.chip { background: #333; padding: 4px 12px; border-radius: 14px; display: flex; align-items: center; gap: 6px; }
|
||||
.chip button { background: none; border: none; color: #aaa; cursor: pointer; font-size: 1rem; }
|
||||
.add-tag input { padding: 8px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 4px; width: 200px; }
|
||||
.tag-error { color: #ff6b6b; font-size: 0.85rem; margin-top: 6px; }
|
||||
.delete-btn { margin-top: 32px; padding: 10px 24px; background: #c0392b; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
|
||||
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.dialog { background: #1a1a1a; padding: 32px; border-radius: 10px; text-align: center; }
|
||||
.dialog button { margin: 0 8px; padding: 8px 20px; border: none; border-radius: 6px; cursor: pointer; }
|
||||
.dialog button:first-of-type { background: #c0392b; color: #fff; }
|
||||
.dialog button:last-of-type { background: #444; color: #e0e0e0; }
|
||||
.not-found { text-align: center; color: #666; padding: 60px; }
|
||||
`],
|
||||
})
|
||||
export class DetailComponent implements OnInit {
|
||||
image: ImageRecord | null = null;
|
||||
loading = true;
|
||||
newTagInput = '';
|
||||
tagError = '';
|
||||
showDeleteDialog = false;
|
||||
|
||||
constructor(
|
||||
public imageService: ImageService,
|
||||
private route: ActivatedRoute,
|
||||
public router: Router,
|
||||
private cdr: ChangeDetectorRef,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (!id) { this.loading = false; return; }
|
||||
this.imageService.get(id).subscribe({
|
||||
next: (img) => { this.image = img; this.loading = false; this.cdr.markForCheck(); },
|
||||
error: () => { this.loading = false; this.cdr.markForCheck(); },
|
||||
});
|
||||
}
|
||||
|
||||
removeTag(tag: string): void {
|
||||
if (!this.image) return;
|
||||
const updated = this.image.tags.filter((t) => t !== tag);
|
||||
this.imageService.updateTags(this.image.id, updated).subscribe({
|
||||
next: (img) => { this.image = img; this.tagError = ''; this.cdr.markForCheck(); },
|
||||
error: (err) => { this.tagError = err?.error?.detail ?? 'Failed to remove tag'; this.cdr.markForCheck(); },
|
||||
});
|
||||
}
|
||||
|
||||
addTag(tag: string): void {
|
||||
if (!this.image || !tag.trim()) return;
|
||||
const normalised = tag.trim().toLowerCase();
|
||||
const updated = [...this.image.tags, normalised];
|
||||
this.imageService.updateTags(this.image.id, updated).subscribe({
|
||||
next: (img) => { this.image = img; this.newTagInput = ''; this.tagError = ''; this.cdr.markForCheck(); },
|
||||
error: (err) => { this.tagError = err?.error?.detail ?? 'Invalid tag'; this.cdr.markForCheck(); },
|
||||
});
|
||||
}
|
||||
|
||||
onEnter(): void { this.addTag(this.newTagInput); }
|
||||
onBlur(): void { if (this.newTagInput.trim()) this.addTag(this.newTagInput); }
|
||||
|
||||
confirmDelete(): void {
|
||||
if (!this.image) return;
|
||||
this.imageService.delete(this.image.id).subscribe({
|
||||
next: () => this.router.navigate(['/']),
|
||||
error: () => { this.showDeleteDialog = false; this.cdr.markForCheck(); },
|
||||
});
|
||||
}
|
||||
|
||||
cancelDelete(): void {
|
||||
this.showDeleteDialog = false;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
goBack(): void { this.router.navigate(['/']); }
|
||||
}
|
||||
49
ui/src/app/library/library.component.spec.ts
Normal file
49
ui/src/app/library/library.component.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { LibraryComponent } from './library.component';
|
||||
import { ImageService } from '../services/image.service';
|
||||
import { routes } from '../app.routes';
|
||||
|
||||
describe('LibraryComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LibraryComponent],
|
||||
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter(routes)],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should render image grid from service response', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const component = fixture.componentInstance;
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(
|
||||
of({
|
||||
items: [
|
||||
{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', created_at: '' },
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const de = fixture.nativeElement as HTMLElement;
|
||||
expect(de.querySelectorAll('.image-card').length).toBe(1);
|
||||
});
|
||||
|
||||
it('should trigger new API call with tags param on filter change', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const component = fixture.componentInstance;
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
const listSpy = spyOn(imgSvc, 'list').and.returnValue(
|
||||
of({ items: [], total: 0, limit: 50, offset: 0 })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.applyFilter(['cat', 'funny']);
|
||||
expect(listSpy).toHaveBeenCalledWith(['cat', 'funny'], jasmine.any(Number), jasmine.any(Number));
|
||||
});
|
||||
});
|
||||
156
ui/src/app/library/library.component.ts
Normal file
156
ui/src/app/library/library.component.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
16
ui/src/app/not-found/not-found.component.ts
Normal file
16
ui/src/app/not-found/not-found.component.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-not-found',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
template: `
|
||||
<div class="not-found">
|
||||
<h2>404 — Page not found</h2>
|
||||
<a routerLink="/">Back to library</a>
|
||||
</div>
|
||||
`,
|
||||
styles: [`.not-found { text-align: center; padding: 80px 16px; } a { color: #4a9eff; }`],
|
||||
})
|
||||
export class NotFoundComponent {}
|
||||
41
ui/src/app/services/image.service.spec.ts
Normal file
41
ui/src/app/services/image.service.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { ImageService } from './image.service';
|
||||
|
||||
describe('ImageService', () => {
|
||||
let service: ImageService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ImageService, provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
service = TestBed.inject(ImageService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should include tags query param when filter is set', () => {
|
||||
service.list(['cat', 'funny'], 50, 0).subscribe();
|
||||
const req = httpMock.expectOne((r) => r.url === '/api/v1/images');
|
||||
expect(req.request.params.get('tags')).toBe('cat,funny');
|
||||
req.flush({ items: [], total: 0, limit: 50, offset: 0 });
|
||||
});
|
||||
|
||||
it('should omit tags query param when filter is empty', () => {
|
||||
service.list([], 50, 0).subscribe();
|
||||
const req = httpMock.expectOne((r) => r.url === '/api/v1/images');
|
||||
expect(req.request.params.has('tags')).toBeFalse();
|
||||
req.flush({ items: [], total: 0, limit: 50, offset: 0 });
|
||||
});
|
||||
|
||||
it('should send correct offset and limit params', () => {
|
||||
service.list([], 25, 75).subscribe();
|
||||
const req = httpMock.expectOne((r) => r.url === '/api/v1/images');
|
||||
expect(req.request.params.get('limit')).toBe('25');
|
||||
expect(req.request.params.get('offset')).toBe('75');
|
||||
req.flush({ items: [], total: 0, limit: 25, offset: 75 });
|
||||
});
|
||||
});
|
||||
64
ui/src/app/services/image.service.ts
Normal file
64
ui/src/app/services/image.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface ImageRecord {
|
||||
id: string;
|
||||
hash: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
storage_key: string;
|
||||
created_at: string;
|
||||
tags: string[];
|
||||
duplicate?: boolean;
|
||||
}
|
||||
|
||||
export interface ImageListResponse {
|
||||
items: ImageRecord[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ImageService {
|
||||
private readonly base = '/api/v1';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
upload(file: File, tags: string[]): Observable<ImageRecord> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (tags.length) {
|
||||
form.append('tags', tags.join(','));
|
||||
}
|
||||
return this.http.post<ImageRecord>(`${this.base}/images`, form);
|
||||
}
|
||||
|
||||
list(tagFilter: string[] = [], limit = 50, offset = 0): Observable<ImageListResponse> {
|
||||
let params = new HttpParams().set('limit', limit).set('offset', offset);
|
||||
if (tagFilter.length) {
|
||||
params = params.set('tags', tagFilter.join(','));
|
||||
}
|
||||
return this.http.get<ImageListResponse>(`${this.base}/images`, { params });
|
||||
}
|
||||
|
||||
get(id: string): Observable<ImageRecord> {
|
||||
return this.http.get<ImageRecord>(`${this.base}/images/${id}`);
|
||||
}
|
||||
|
||||
getFileUrl(id: string): string {
|
||||
return `${this.base}/images/${id}/file`;
|
||||
}
|
||||
|
||||
updateTags(id: string, tags: string[]): Observable<ImageRecord> {
|
||||
return this.http.patch<ImageRecord>(`${this.base}/images/${id}/tags`, { tags });
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/images/${id}`);
|
||||
}
|
||||
}
|
||||
33
ui/src/app/services/tag.service.spec.ts
Normal file
33
ui/src/app/services/tag.service.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { TagService } from './tag.service';
|
||||
|
||||
describe('TagService', () => {
|
||||
let service: TagService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [TagService, provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
service = TestBed.inject(TagService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should include q param when prefix is provided', () => {
|
||||
service.list('cat').subscribe();
|
||||
const req = httpMock.expectOne((r) => r.url === '/api/v1/tags');
|
||||
expect(req.request.params.get('q')).toBe('cat');
|
||||
req.flush({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
});
|
||||
|
||||
it('should omit q param when prefix is empty', () => {
|
||||
service.list().subscribe();
|
||||
const req = httpMock.expectOne((r) => r.url === '/api/v1/tags');
|
||||
expect(req.request.params.has('q')).toBeFalse();
|
||||
req.flush({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
});
|
||||
});
|
||||
31
ui/src/app/services/tag.service.ts
Normal file
31
ui/src/app/services/tag.service.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface TagRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
image_count: number;
|
||||
}
|
||||
|
||||
export interface TagListResponse {
|
||||
items: TagRecord[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TagService {
|
||||
private readonly base = '/api/v1';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
list(prefix?: string, limit = 100, offset = 0): Observable<TagListResponse> {
|
||||
let params = new HttpParams().set('limit', limit).set('offset', offset);
|
||||
if (prefix) {
|
||||
params = params.set('q', prefix);
|
||||
}
|
||||
return this.http.get<TagListResponse>(`${this.base}/tags`, { params });
|
||||
}
|
||||
}
|
||||
83
ui/src/app/upload/upload.component.spec.ts
Normal file
83
ui/src/app/upload/upload.component.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { UploadComponent } from './upload.component';
|
||||
import { ImageService } from '../services/image.service';
|
||||
import { routes } from '../app.routes';
|
||||
|
||||
describe('UploadComponent', () => {
|
||||
let component: UploadComponent;
|
||||
|
||||
function makeImageService(overrides: Partial<ImageService> = {}): jasmine.SpyObj<ImageService> {
|
||||
return jasmine.createSpyObj<ImageService>('ImageService', { upload: of({} as any), ...overrides });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [UploadComponent],
|
||||
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter(routes)],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should normalise tag input: lowercases and splits on comma/space', () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.tagInput = 'CAT, Funny reaction';
|
||||
const parsed = component.parseTagInput(component.tagInput);
|
||||
expect(parsed).toEqual(['cat', 'funny', 'reaction']);
|
||||
});
|
||||
|
||||
it('should split on commas', () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
const parsed = component.parseTagInput('a,b,c');
|
||||
expect(parsed).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should filter empty tokens', () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
const parsed = component.parseTagInput(' ,, cat ,,');
|
||||
expect(parsed).toEqual(['cat']);
|
||||
});
|
||||
|
||||
it('on duplicate response: shows toast and navigates to detail', async () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
const router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate');
|
||||
|
||||
const mockSvc = makeImageService({
|
||||
upload: of({ id: 'abc', duplicate: true } as any),
|
||||
} as any);
|
||||
(component as any).imageService = mockSvc;
|
||||
|
||||
await component.handleUploadResponse({ id: 'abc', duplicate: true } as any);
|
||||
expect(component.toastMessage).toContain('library');
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/images', 'abc']);
|
||||
});
|
||||
|
||||
it('on success response: shows success toast and navigates to detail', async () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
const router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate');
|
||||
|
||||
await component.handleUploadResponse({ id: 'xyz', duplicate: false } as any);
|
||||
expect(component.toastMessage).toBeTruthy();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/images', 'xyz']);
|
||||
});
|
||||
|
||||
it('on error response: shows inline error, no navigation', async () => {
|
||||
const fixture = TestBed.createComponent(UploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
const router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate');
|
||||
|
||||
component.handleUploadError({ status: 422, error: { detail: 'bad file', code: 'invalid_mime_type' } });
|
||||
expect(component.errorMessage).toBeTruthy();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
133
ui/src/app/upload/upload.component.ts
Normal file
133
ui/src/app/upload/upload.component.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ImageRecord, ImageService } from '../services/image.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-upload',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="upload-page">
|
||||
<h1>Upload Image</h1>
|
||||
|
||||
<div
|
||||
class="drop-zone"
|
||||
[class.drag-over]="isDragOver"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="isDragOver = false"
|
||||
(drop)="onDrop($event)"
|
||||
(click)="fileInput.click()"
|
||||
>
|
||||
<p>{{ selectedFile ? selectedFile.name : 'Drag & drop or click to browse' }}</p>
|
||||
<input #fileInput type="file" accept="image/*" hidden (change)="onFileChange($event)" />
|
||||
</div>
|
||||
|
||||
<div class="tag-input" *ngIf="selectedFile">
|
||||
<label>Tags (comma or space separated)</label>
|
||||
<input [(ngModel)]="tagInput" placeholder="cat, funny, reaction" />
|
||||
<div class="chips">
|
||||
<span *ngFor="let tag of parseTagInput(tagInput)" class="chip">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button [disabled]="!selectedFile || uploading" (click)="submit()">
|
||||
{{ uploading ? 'Uploading…' : 'Upload' }}
|
||||
</button>
|
||||
|
||||
<p class="toast" *ngIf="toastMessage">{{ toastMessage }}</p>
|
||||
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.upload-page { max-width: 600px; margin: 40px auto; padding: 0 16px; }
|
||||
.drop-zone { border: 2px dashed #555; border-radius: 8px; padding: 40px; text-align: center; cursor: pointer; }
|
||||
.drop-zone.drag-over { border-color: #fff; background: #1a1a1a; }
|
||||
.tag-input { margin: 16px 0; }
|
||||
label { display: block; margin-bottom: 4px; font-size: 0.9rem; color: #aaa; }
|
||||
input[type=text], input:not([type]) { width: 100%; padding: 8px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 4px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.chip { background: #333; padding: 2px 10px; border-radius: 12px; font-size: 0.85rem; }
|
||||
button { padding: 10px 24px; background: #4a9eff; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
.toast { color: #4a9eff; margin-top: 12px; }
|
||||
.error { color: #ff6b6b; margin-top: 12px; }
|
||||
`],
|
||||
})
|
||||
export class UploadComponent {
|
||||
selectedFile: File | null = null;
|
||||
tagInput = '';
|
||||
uploading = false;
|
||||
toastMessage = '';
|
||||
errorMessage = '';
|
||||
isDragOver = false;
|
||||
|
||||
constructor(
|
||||
private imageService: ImageService,
|
||||
private router: Router,
|
||||
private cdr: ChangeDetectorRef,
|
||||
) {}
|
||||
|
||||
parseTagInput(input: string): string[] {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/,/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
this.isDragOver = true;
|
||||
}
|
||||
|
||||
onDrop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
this.isDragOver = false;
|
||||
const file = event.dataTransfer?.files[0];
|
||||
if (file) this.selectedFile = file;
|
||||
}
|
||||
|
||||
onFileChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.selectedFile = input.files?.[0] ?? null;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.selectedFile) return;
|
||||
this.uploading = true;
|
||||
this.errorMessage = '';
|
||||
this.toastMessage = '';
|
||||
|
||||
const tags = this.parseTagInput(this.tagInput);
|
||||
this.imageService.upload(this.selectedFile, tags).subscribe({
|
||||
next: (res) => {
|
||||
this.uploading = false;
|
||||
this.handleUploadResponse(res);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: (err) => {
|
||||
this.uploading = false;
|
||||
this.handleUploadError(err);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async handleUploadResponse(res: ImageRecord): Promise<void> {
|
||||
if (res.duplicate) {
|
||||
this.toastMessage = 'Already in your library';
|
||||
} else {
|
||||
this.toastMessage = 'Image uploaded successfully!';
|
||||
}
|
||||
await this.router.navigate(['/images', res.id]);
|
||||
}
|
||||
|
||||
handleUploadError(err: any): void {
|
||||
const apiError = err?.error;
|
||||
this.errorMessage = apiError?.detail ?? 'Upload failed. Please try again.';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user