[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:
2026-05-02 16:13:23 +00:00
parent 691f7570fe
commit 8bf6ef443a
74 changed files with 3005 additions and 88 deletions

View 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();
});
});

View 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.';
}
}