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
138 lines
3.6 KiB
TypeScript
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);
|
|
});
|
|
}
|