Photo Management (per specs/photo-management.md): - Sharp integration for 3-size compression (thumb/medium/full) - WebP output with 80-90% quality - Client-side compression with browser-image-compression - PhotoUpload component with camera/drag-drop support - Upload API with bulk support and stats endpoint Testing: - Backend: Jest tests for all major API endpoints - Frontend: Vitest tests for utilities and API clients - CI: Updated Forgejo workflow for test execution Specs (100% coverage): - visitor-management.md (Phase 8) - messaging.md (Phase 9) - audit-and-documents.md (Phase 10) - accessibility-i18n.md (Phase 11) - hardware-integration.md (Phase 12) - advanced-features.md (Phase 13) Documentation: - OpenAPI 3.0 spec (docs/openapi.yaml) - All endpoints documented with schemas
132 lines
3.8 KiB
TypeScript
132 lines
3.8 KiB
TypeScript
/**
|
|
* Photo Upload API Client
|
|
*/
|
|
|
|
import api from './api';
|
|
import { compressPhoto, compressPhotos, CompressedPhoto } from './photoCompression';
|
|
|
|
export interface UploadedPhoto {
|
|
success: boolean;
|
|
photoId: string;
|
|
urls: {
|
|
thumb: string;
|
|
medium: string;
|
|
full: string;
|
|
};
|
|
metadata: {
|
|
originalSize: number;
|
|
originalFormat: string;
|
|
originalDimensions: { width: number; height: number };
|
|
compressedSizes: Record<string, number>;
|
|
savingsPercent: number;
|
|
format: string;
|
|
};
|
|
uploadedAt: string;
|
|
}
|
|
|
|
export interface BulkUploadResult {
|
|
success: boolean;
|
|
uploaded: number;
|
|
failed: number;
|
|
results: Array<{
|
|
photoId: string;
|
|
filename: string;
|
|
urls: Record<string, string>;
|
|
}>;
|
|
errors?: Array<{
|
|
filename: string;
|
|
error: string;
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* Upload a single photo with compression
|
|
*/
|
|
export async function uploadPhoto(
|
|
file: File,
|
|
onProgress?: (progress: number) => void
|
|
): Promise<UploadedPhoto> {
|
|
// Compress client-side first
|
|
const compressed = await compressPhoto(file);
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', compressed.file, file.name);
|
|
|
|
const response = await api.post<UploadedPhoto>('/api/upload/photo', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
onUploadProgress: (progressEvent) => {
|
|
if (progressEvent.total && onProgress) {
|
|
const progress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
|
|
onProgress(progress);
|
|
}
|
|
}
|
|
});
|
|
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* Upload multiple photos with compression
|
|
*/
|
|
export async function uploadPhotos(
|
|
files: File[],
|
|
onProgress?: (completed: number, total: number) => void
|
|
): Promise<BulkUploadResult> {
|
|
// Compress all files first
|
|
const compressed = await compressPhotos(files, {}, (completed, total) => {
|
|
onProgress?.(completed, total * 2); // First half is compression
|
|
});
|
|
|
|
const formData = new FormData();
|
|
compressed.forEach((photo, index) => {
|
|
formData.append('files', photo.file, files[index].name);
|
|
});
|
|
|
|
const response = await api.post<BulkUploadResult>('/api/upload/photos', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
onUploadProgress: (progressEvent) => {
|
|
if (progressEvent.total && onProgress) {
|
|
const uploadProgress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
|
|
// Second half is upload (50-100)
|
|
onProgress?.(files.length + Math.round(files.length * uploadProgress / 100), files.length * 2);
|
|
}
|
|
}
|
|
});
|
|
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* Get photo URL (with auth token if needed)
|
|
*/
|
|
export function getPhotoUrl(path: string, size: 'thumb' | 'medium' | 'full' = 'medium'): string {
|
|
// If it's already a full URL, return as-is
|
|
if (path.startsWith('http')) return path;
|
|
|
|
// If path includes size, return with api prefix
|
|
if (path.includes('/thumb.webp') || path.includes('/medium.webp') || path.includes('/full.webp')) {
|
|
return `/api/upload/photo${path.replace('/photos', '')}`;
|
|
}
|
|
|
|
// Otherwise, assume it's a photoId and construct the URL
|
|
return `/api/upload/photo/${path}/${size}.webp`;
|
|
}
|
|
|
|
/**
|
|
* Delete a photo
|
|
*/
|
|
export async function deletePhoto(photoId: string): Promise<void> {
|
|
await api.delete(`/api/upload/photo/${photoId}`);
|
|
}
|
|
|
|
/**
|
|
* Get upload statistics
|
|
*/
|
|
export async function getUploadStats(): Promise<{
|
|
storagePath: string;
|
|
diskUsage: string;
|
|
sizes: Record<string, { width: number; height: number; quality: number }>;
|
|
}> {
|
|
const response = await api.get('/api/upload/stats');
|
|
return response.data;
|
|
}
|