- Add Pillow dependency and thumbnail.py with generate_thumbnail() — produces
WebP ≤400px, preserves aspect ratio, never upscales, handles GIF frame 0
- Alembic migration 002 adds nullable thumbnail_key column to images table
- Upload route generates thumbnail via asyncio.to_thread (non-blocking),
stores at {hash}-thumb; failure is tolerated and upload succeeds with null key
- New GET /api/v1/images/{id}/thumbnail endpoint: serves WebP thumbnail or
falls back to original for pre-feature images; ETag + immutable cache headers
- Delete route cleans up thumbnail storage object alongside original
- Library grid switches from /file to /thumbnail for all image src bindings
- 59 tests passing (46 existing + 13 new across unit, upload, serving, delete)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
70 lines
1.8 KiB
TypeScript
70 lines
1.8 KiB
TypeScript
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;
|
|
thumbnail_key: string | null;
|
|
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`;
|
|
}
|
|
|
|
getThumbnailUrl(id: string): string {
|
|
return `${this.base}/images/${id}/thumbnail`;
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|