ca-grow-ops-manager/frontend/src/lib/photoCompression.ts
fullsizemalt 558e0ac1e2
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
Test / backend-test (push) Failing after 0s
Test / frontend-test (push) Failing after 0s
feat: Full Spec Kit compliance implementation
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
2025-12-11 09:53:32 -08:00

138 lines
3.6 KiB
TypeScript

/**
* Photo Compression Utility
* Client-side image compression before upload per specs/photo-management.md
*/
import imageCompression from 'browser-image-compression';
export interface CompressedPhoto {
file: File;
preview: string;
originalSize: number;
compressedSize: number;
savingsPercent: number;
}
export interface CompressionOptions {
maxSizeMB?: number;
maxWidthOrHeight?: number;
quality?: number;
useWebWorker?: boolean;
}
const DEFAULT_OPTIONS: CompressionOptions = {
maxSizeMB: 1, // Max 1MB after compression
maxWidthOrHeight: 1920,
useWebWorker: true,
};
/**
* Compress a single photo before upload
*/
export async function compressPhoto(
file: File,
options: CompressionOptions = {}
): Promise<CompressedPhoto> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const originalSize = file.size;
try {
const compressedFile = await imageCompression(file, {
maxSizeMB: opts.maxSizeMB!,
maxWidthOrHeight: opts.maxWidthOrHeight!,
useWebWorker: opts.useWebWorker!,
fileType: 'image/webp',
});
const preview = await createPreview(compressedFile);
const compressedSize = compressedFile.size;
const savingsPercent = Math.round((1 - compressedSize / originalSize) * 100);
return {
file: compressedFile,
preview,
originalSize,
compressedSize,
savingsPercent,
};
} catch (error) {
console.error('Photo compression failed:', error);
// Return original if compression fails
const preview = await createPreview(file);
return {
file,
preview,
originalSize,
compressedSize: originalSize,
savingsPercent: 0,
};
}
}
/**
* Compress multiple photos
*/
export async function compressPhotos(
files: File[],
options: CompressionOptions = {},
onProgress?: (completed: number, total: number) => void
): Promise<CompressedPhoto[]> {
const results: CompressedPhoto[] = [];
for (let i = 0; i < files.length; i++) {
const result = await compressPhoto(files[i], options);
results.push(result);
onProgress?.(i + 1, files.length);
}
return results;
}
/**
* Create a preview URL for an image file
*/
async function createPreview(file: File): Promise<string> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(file);
});
}
/**
* Format file size for display
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Check if file is a valid image type
*/
export function isValidImageType(file: File): boolean {
const validTypes = [
'image/jpeg',
'image/png',
'image/webp',
'image/heic',
'image/heif',
];
return validTypes.includes(file.type);
}
/**
* Get image dimensions
*/
export async function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve({ width: img.width, height: img.height });
URL.revokeObjectURL(img.src);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}