[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:
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