From 11cd7d1c531d8b2ae37c3d1aa7480d249cd4f079 Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 2 Sep 2026 16:18:01 +0600 Subject: [PATCH 1/2] feat: standardize image upload limits and add client-side WebP compression - Standardize backend image upload rules across FormRequests and controllers to 5MB and mimes:jpg,jpeg,png,webp - Add client-side sequential image compression engine (imageCompression.ts) using HTML5 Canvas to convert uploads to WebP (quality 0.85, max 2048px) - Add useImageUpload composable and reusable ImageUpload.vue component - Integrate client-side compression into BulkImageModal, CreateResourceModal, Profile, Onboarding, and Forum question/reply views --- .../Controllers/Admin/EmailController.php | 2 +- app/Http/Controllers/AuthController.php | 2 +- app/Http/Requests/Blog/StoreBlogRequest.php | 2 +- app/Http/Requests/Blog/UpdateBlogRequest.php | 2 +- .../Requests/Notice/UpdateNoticeRequest.php | 2 +- .../Requests/Profile/UpdateProfileRequest.php | 2 +- .../Resource/BulkImageStoreRequest.php | 2 +- .../Resource/StoreResourceRequest.php | 4 +- .../Resource/UpdateResourceRequest.php | 4 +- app/Http/Requests/User/StoreUserRequest.php | 2 +- app/Http/Requests/User/UpdateUserRequest.php | 2 +- resources/js/components/ImageUpload.vue | 311 ++++++++++++++++++ .../js/components/admin/BulkImageModal.vue | 44 ++- .../components/admin/CreateResourceModal.vue | 39 ++- resources/js/lib/imageCompression.ts | 249 ++++++++++++++ resources/js/lib/useImageUpload.ts | 125 +++++++ resources/js/pages/Forum/Create.vue | 58 +++- resources/js/pages/Forum/Show.vue | 68 +++- resources/js/pages/Profile.vue | 53 ++- resources/js/pages/auth/Onboarding.vue | 65 +++- 20 files changed, 970 insertions(+), 68 deletions(-) create mode 100644 resources/js/components/ImageUpload.vue create mode 100644 resources/js/lib/imageCompression.ts create mode 100644 resources/js/lib/useImageUpload.ts diff --git a/app/Http/Controllers/Admin/EmailController.php b/app/Http/Controllers/Admin/EmailController.php index f008b03b..6058e3e2 100644 --- a/app/Http/Controllers/Admin/EmailController.php +++ b/app/Http/Controllers/Admin/EmailController.php @@ -50,7 +50,7 @@ public function store(Request $request): RedirectResponse 'recipient_email' => ['required_if:recipient_type,single', 'nullable', 'email', 'max:255'], 'subject' => ['required', 'string', 'max:255'], 'body' => ['required', 'string'], - 'image' => ['sometimes', 'nullable', 'image', 'max:5120'], + 'image' => ['sometimes', 'nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], ]); $subject = $validated['subject']; diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 7f93907e..ae35768b 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -140,7 +140,7 @@ public function completeOnboarding(Request $request) new CleanText, ], 'school' => ['required', 'string', 'max:255', new CleanText], - 'image' => ['sometimes', 'nullable', 'image', 'max:5120'], + 'image' => ['sometimes', 'nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], ], [ 'school.required' => 'Please enter your school, college, or institution name.', 'username.regex' => 'Username can only contain letters, numbers, and underscores.', diff --git a/app/Http/Requests/Blog/StoreBlogRequest.php b/app/Http/Requests/Blog/StoreBlogRequest.php index 94cf2e1a..aa6f87c1 100644 --- a/app/Http/Requests/Blog/StoreBlogRequest.php +++ b/app/Http/Requests/Blog/StoreBlogRequest.php @@ -38,7 +38,7 @@ public function rules(): array 'nullable', 'image', 'mimes:jpg,jpeg,png,webp', - 'max:10240', + 'max:5120', ], 'is_published' => ['required', 'boolean'], diff --git a/app/Http/Requests/Blog/UpdateBlogRequest.php b/app/Http/Requests/Blog/UpdateBlogRequest.php index 8e4ceff6..e8e4d048 100644 --- a/app/Http/Requests/Blog/UpdateBlogRequest.php +++ b/app/Http/Requests/Blog/UpdateBlogRequest.php @@ -60,7 +60,7 @@ public function rules(): array 'nullable', 'image', 'mimes:jpg,jpeg,png,webp', - 'max:10240', + 'max:5120', ], 'is_published' => [ diff --git a/app/Http/Requests/Notice/UpdateNoticeRequest.php b/app/Http/Requests/Notice/UpdateNoticeRequest.php index ecce88e4..40ef9d27 100644 --- a/app/Http/Requests/Notice/UpdateNoticeRequest.php +++ b/app/Http/Requests/Notice/UpdateNoticeRequest.php @@ -25,7 +25,7 @@ public function rules(): array return [ 'title' => ['required', 'string', 'max:255'], 'message' => ['required', 'string'], - 'image' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'], + 'image' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], 'remove_image' => ['nullable', 'boolean'], 'show_button' => ['required', 'boolean'], 'button_title' => ['nullable', 'required_if:show_button,true', 'string', 'max:100'], diff --git a/app/Http/Requests/Profile/UpdateProfileRequest.php b/app/Http/Requests/Profile/UpdateProfileRequest.php index 96a4be11..6cd6c95c 100644 --- a/app/Http/Requests/Profile/UpdateProfileRequest.php +++ b/app/Http/Requests/Profile/UpdateProfileRequest.php @@ -35,7 +35,7 @@ public function rules(): array Rule::unique('users', 'username')->ignore($this->user()->id), new CleanText, ], - 'file' => ['sometimes', 'nullable', 'image', 'max:2048'], + 'file' => ['sometimes', 'nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], 'about' => ['sometimes', 'nullable', 'string', 'max:1000', new CleanText], 'institution' => ['sometimes', 'nullable', 'string', 'max:255', new CleanText], 'facebook' => ['sometimes', 'nullable', 'string', 'max:255'], diff --git a/app/Http/Requests/Resource/BulkImageStoreRequest.php b/app/Http/Requests/Resource/BulkImageStoreRequest.php index bbfca6ec..a9aad0ce 100644 --- a/app/Http/Requests/Resource/BulkImageStoreRequest.php +++ b/app/Http/Requests/Resource/BulkImageStoreRequest.php @@ -27,7 +27,7 @@ public function rules(): array 'custom_titles' => 'required|array|max:20', 'custom_titles.*' => 'required|string|max:100', 'files' => 'required|array|min:1|max:20', - 'files.*' => 'required|image|max:10240', // 10MB Limit + 'files.*' => 'required|image|mimes:jpg,jpeg,png,webp|max:5120', ]; } } diff --git a/app/Http/Requests/Resource/StoreResourceRequest.php b/app/Http/Requests/Resource/StoreResourceRequest.php index e3df0594..72f882d9 100644 --- a/app/Http/Requests/Resource/StoreResourceRequest.php +++ b/app/Http/Requests/Resource/StoreResourceRequest.php @@ -32,8 +32,8 @@ public function rules(): array 'file' => [ 'nullable', 'file', - 'max:10000', - 'mimes:jpg,jpeg,png', + 'max:5120', + 'mimes:jpg,jpeg,png,webp', Rule::requiredIf($this->resource_type === 'image'), ], diff --git a/app/Http/Requests/Resource/UpdateResourceRequest.php b/app/Http/Requests/Resource/UpdateResourceRequest.php index 934391b6..aed5893d 100644 --- a/app/Http/Requests/Resource/UpdateResourceRequest.php +++ b/app/Http/Requests/Resource/UpdateResourceRequest.php @@ -23,8 +23,8 @@ public function rules(): array 'file' => [ 'nullable', 'file', - 'max:10000', - 'mimes:jpg,jpeg,png', + 'max:5120', + 'mimes:jpg,jpeg,png,webp', Rule::requiredIf( $this->resource_type === 'image' && ! $this->route('resource')->file_path diff --git a/app/Http/Requests/User/StoreUserRequest.php b/app/Http/Requests/User/StoreUserRequest.php index 82d38140..9d34b96c 100644 --- a/app/Http/Requests/User/StoreUserRequest.php +++ b/app/Http/Requests/User/StoreUserRequest.php @@ -32,7 +32,7 @@ public function rules(): array 'role' => ['nullable', 'string'], 'permissions' => ['nullable', 'array'], 'permissions.*' => ['string', 'exists:permissions,name'], - 'file' => ['nullable', 'image', 'max:2048'], + 'file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], 'about' => ['nullable', 'string', 'max:1000', new CleanText], 'title' => ['nullable', 'string', 'max:255', new CleanText], 'institution' => ['nullable', 'string', 'max:255', new CleanText], diff --git a/app/Http/Requests/User/UpdateUserRequest.php b/app/Http/Requests/User/UpdateUserRequest.php index 060bf0c3..5cf27228 100644 --- a/app/Http/Requests/User/UpdateUserRequest.php +++ b/app/Http/Requests/User/UpdateUserRequest.php @@ -38,7 +38,7 @@ public function rules(): array new CleanText, ], 'email' => ['sometimes', 'email', 'unique:users,email,'.$user->id], - 'file' => ['sometimes', 'nullable', 'image', 'max:2048'], + 'file' => ['sometimes', 'nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], 'about' => ['sometimes', 'nullable', 'string', 'max:1000', new CleanText], 'title' => ['sometimes', 'nullable', 'string', 'max:255', new CleanText], 'institution' => ['sometimes', 'nullable', 'string', 'max:255', new CleanText], diff --git a/resources/js/components/ImageUpload.vue b/resources/js/components/ImageUpload.vue new file mode 100644 index 00000000..605d1199 --- /dev/null +++ b/resources/js/components/ImageUpload.vue @@ -0,0 +1,311 @@ + + + diff --git a/resources/js/components/admin/BulkImageModal.vue b/resources/js/components/admin/BulkImageModal.vue index 2e62efa0..2bf2fc7a 100644 --- a/resources/js/components/admin/BulkImageModal.vue +++ b/resources/js/components/admin/BulkImageModal.vue @@ -10,6 +10,7 @@ import { } from 'lucide-vue-next'; import { ref, computed, watch, onUnmounted } from 'vue'; import BaseModal from '@/components/BaseModal.vue'; +import { compressImagesSequentially } from '@/lib/imageCompression'; const props = defineProps<{ isOpen: boolean; @@ -28,6 +29,8 @@ const isDragging = ref(false); const fileLimitError = ref(''); const errorMessage = ref(''); const isSaving = ref(false); +const isCompressing = ref(false); +const compressionStatusText = ref(''); const uploadProgress = ref(null); const isProcessingServer = ref(false); @@ -108,6 +111,8 @@ const clearAll = () => { fileLimitError.value = ''; errorMessage.value = ''; isSaving.value = false; + isCompressing.value = false; + compressionStatusText.value = ''; uploadProgress.value = null; isProcessingServer.value = false; }; @@ -134,22 +139,40 @@ onUnmounted(() => { clearAll(); }); -const submitForm = () => { +const submitForm = async () => { if (selectedFiles.value.length === 0 || isSaving.value) { return; } isSaving.value = true; + errorMessage.value = ''; + + let filesToUpload = selectedFiles.value.map((item) => item.file); + + // If any file is larger than 300KB, optimize sequentially + const needsOptimization = filesToUpload.some((f) => f.size > 300 * 1024); + + if (needsOptimization) { + isCompressing.value = true; + compressionStatusText.value = 'Preparing images...'; + filesToUpload = await compressImagesSequentially( + filesToUpload, + (processed, total) => { + compressionStatusText.value = `Optimizing image ${processed} of ${total} to WebP...`; + }, + ); + isCompressing.value = false; + } + uploadProgress.value = 0; isProcessingServer.value = false; - errorMessage.value = ''; const payload: Record = { node_id: props.node.id, naming_strategy: namingStrategy.value, naming_prefix: namingPrefix.value, start_number: startNumber.value, - files: selectedFiles.value.map((item) => item.file), + files: filesToUpload, custom_titles: processedTitles.value, }; @@ -427,9 +450,22 @@ const submitForm = () => { + +
+
+ + {{ compressionStatusText }} +
+
+
diff --git a/resources/js/components/admin/CreateResourceModal.vue b/resources/js/components/admin/CreateResourceModal.vue index 70c7ec39..af011a05 100644 --- a/resources/js/components/admin/CreateResourceModal.vue +++ b/resources/js/components/admin/CreateResourceModal.vue @@ -12,6 +12,7 @@ import { } from 'lucide-vue-next'; import { ref, computed, watch } from 'vue'; import BaseModal from '@/components/BaseModal.vue'; +import { compressImage } from '@/lib/imageCompression'; const props = defineProps<{ isOpen: boolean; @@ -67,6 +68,7 @@ const content = ref(''); const externalUrl = ref(''); const file = ref(null); const isSaving = ref(false); +const isCompressing = ref(false); const errorMessage = ref(''); const requiresFile = computed(() => resourceType.value === 'image'); @@ -91,6 +93,7 @@ const initForm = () => { errorMessage.value = ''; isSaving.value = false; + isCompressing.value = false; }; watch( @@ -102,11 +105,20 @@ watch( }, ); -const handleFileSelect = (event: Event) => { +const handleFileSelect = async (event: Event) => { const target = event.target as HTMLInputElement; if (target.files && target.files[0]) { - file.value = target.files[0]; + const raw = target.files[0]; + + try { + isCompressing.value = true; + file.value = await compressImage(raw); + } catch { + file.value = raw; + } finally { + isCompressing.value = false; + } } }; @@ -320,31 +332,42 @@ const submitForm = () => { type="file" id="resource_file_upload" class="hidden" + :disabled="isCompressing || isSaving" @change="handleFileSelect" - accept="image/jpeg,image/png,image/jpg" + accept="image/jpeg,image/png,image/jpg,image/webp" />
diff --git a/resources/js/lib/imageCompression.ts b/resources/js/lib/imageCompression.ts new file mode 100644 index 00000000..73630208 --- /dev/null +++ b/resources/js/lib/imageCompression.ts @@ -0,0 +1,249 @@ +export interface CompressOptions { + maxWidth?: number; + maxHeight?: number; + quality?: number; + targetFormat?: 'image/webp' | 'image/jpeg' | 'image/png'; + skipThresholdKB?: number; +} + +const DEFAULT_OPTIONS: Required = { + maxWidth: 2048, + maxHeight: 2048, + quality: 0.85, + targetFormat: 'image/webp', + skipThresholdKB: 300, +}; + +/** + * Checks if a file is an image that should be compressed. + * GIFs (to preserve animation) and SVGs (vector graphics) are excluded. + */ +export function isCompressibleImage(file: File): boolean { + if (!file.type.startsWith('image/')) { + return false; + } + + const uncompressibleTypes = ['image/gif', 'image/svg+xml']; + + return !uncompressibleTypes.includes(file.type); +} + +/** + * Formats bytes into human-readable string (e.g. 1.2 MB, 450 KB). + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) { + return '0 B'; + } + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +/** + * Calculates scaled dimensions while preserving aspect ratio (never upscales). + */ +function calculateDimensions( + originalWidth: number, + originalHeight: number, + maxWidth: number, + maxHeight: number, +): { width: number; height: number } { + let width = originalWidth; + let height = originalHeight; + + if (width > maxWidth) { + height = Math.round((height * maxWidth) / width); + width = maxWidth; + } + + if (height > maxHeight) { + width = Math.round((width * maxHeight) / height); + height = maxHeight; + } + + return { width, height }; +} + +/** + * Loads an image from a File into an ImageBitmap or HTMLImageElement, + * correctly applying EXIF orientation. + */ +async function loadImageSource(file: File): Promise<{ + source: ImageBitmap | HTMLImageElement; + width: number; + height: number; + cleanup: () => void; +}> { + if (typeof createImageBitmap === 'function') { + try { + // Modern standard: automatically handles EXIF orientation + const bitmap = await createImageBitmap(file, { + imageOrientation: 'from-image', + }); + + return { + source: bitmap, + width: bitmap.width, + height: bitmap.height, + cleanup: () => bitmap.close(), + }; + } catch { + // Fall back to Image element if createImageBitmap with options fails + } + } + + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = () => { + resolve({ + source: img, + width: img.naturalWidth, + height: img.naturalHeight, + cleanup: () => URL.revokeObjectURL(url), + }); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error(`Failed to load image: ${file.name}`)); + }; + + img.src = url; + }); +} + +/** + * Compresses a single image file on the client side using HTML5 Canvas. + * Converts to WebP format (or JPEG fallback) and reduces dimensions to max 2048px. + */ +export async function compressImage( + file: File, + options: CompressOptions = {}, +): Promise { + // 1. Skip non-raster or already tiny files + if (!isCompressibleImage(file)) { + return file; + } + + const opts = { ...DEFAULT_OPTIONS, ...options }; + + // If file is already smaller than the skip threshold and within bounds, skip + if ( + file.size <= opts.skipThresholdKB * 1024 && + file.type === opts.targetFormat + ) { + return file; + } + + let sourceObj: { + source: ImageBitmap | HTMLImageElement; + width: number; + height: number; + cleanup: () => void; + } | null = null; + let canvas: HTMLCanvasElement | null = null; + + try { + sourceObj = await loadImageSource(file); + const { width, height } = calculateDimensions( + sourceObj.width, + sourceObj.height, + opts.maxWidth, + opts.maxHeight, + ); + + canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + + if (!ctx) { + return file; + } + + // Draw image onto canvas + ctx.drawImage(sourceObj.source, 0, 0, width, height); + + // Convert canvas to Blob + const blob = await new Promise((resolve) => { + canvas!.toBlob((b) => resolve(b), opts.targetFormat, opts.quality); + }); + + if (!blob) { + // If WebP export failed, fallback to JPEG + const fallbackBlob = await new Promise((resolve) => { + canvas!.toBlob((b) => resolve(b), 'image/jpeg', opts.quality); + }); + + if (!fallbackBlob || fallbackBlob.size >= file.size) { + return file; + } + + const newName = file.name.replace(/\.[^/.]+$/, '') + '.jpg'; + + return new File([fallbackBlob], newName, { type: 'image/jpeg' }); + } + + // Only use compressed file if it's actually smaller than the original + if (blob.size >= file.size && file.type === opts.targetFormat) { + return file; + } + + const extension = opts.targetFormat === 'image/webp' ? '.webp' : '.jpg'; + const newName = file.name.replace(/\.[^/.]+$/, '') + extension; + + return new File([blob], newName, { type: opts.targetFormat }); + } catch (err) { + console.warn( + `[compressImage] Failed to compress ${file.name}, using original:`, + err, + ); + + return file; + } finally { + // Explicitly clean up memory + if (sourceObj) { + sourceObj.cleanup(); + } + + if (canvas) { + canvas.width = 0; + canvas.height = 0; + canvas = null; + } + } +} + +/** + * Sequentially compresses an array of images one by one. + * This guarantees peak RAM on budget mobile devices never exceeds ~80MB, + * completely preventing out-of-memory browser tab crashes. + */ +export async function compressImagesSequentially( + files: File[], + onProgress?: (processed: number, total: number, currentFile: File) => void, + options: CompressOptions = {}, +): Promise { + const total = files.length; + const results: File[] = []; + + for (let i = 0; i < total; i++) { + const file = files[i]; + onProgress?.(i + 1, total, file); + + const compressed = await compressImage(file, options); + results.push(compressed); + + // Small yield to browser event loop to let garbage collection occur + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + return results; +} diff --git a/resources/js/lib/useImageUpload.ts b/resources/js/lib/useImageUpload.ts new file mode 100644 index 00000000..764e244d --- /dev/null +++ b/resources/js/lib/useImageUpload.ts @@ -0,0 +1,125 @@ +import { ref, onUnmounted } from 'vue'; +import { compressImage, formatFileSize } from './imageCompression'; +import type { CompressOptions } from './imageCompression'; + +export interface UseImageUploadOptions { + maxOriginalSizeMB?: number; // Max size the file picker will accept before compression (default: 20MB) + allowedTypes?: string[]; // Allowed MIME types + compressOptions?: CompressOptions; + onCompressed?: (file: File) => void; + onError?: (errorMsg: string) => void; +} + +export function useImageUpload(options: UseImageUploadOptions = {}) { + const maxOriginalSizeMB = options.maxOriginalSizeMB ?? 20; + const allowedTypes = options.allowedTypes ?? [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + ]; + + const file = ref(null); + const previewUrl = ref(null); + const isCompressing = ref(false); + const error = ref(null); + const originalSizeFormatted = ref(null); + const compressedSizeFormatted = ref(null); + + const cleanupPreview = () => { + if (previewUrl.value && previewUrl.value.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl.value); + previewUrl.value = null; + } + }; + + const clear = () => { + cleanupPreview(); + file.value = null; + error.value = null; + originalSizeFormatted.value = null; + compressedSizeFormatted.value = null; + isCompressing.value = false; + }; + + const processFile = async (rawFile: File): Promise => { + error.value = null; + + // 1. Validate file type + if (!allowedTypes.includes(rawFile.type)) { + const msg = 'অনুমোদিত ফরম্যাট: JPG, PNG, WEBP (অথবা GIF)।'; + error.value = msg; + options.onError?.(msg); + + return null; + } + + // 2. Validate original size before compression (e.g. max 20MB) + if (rawFile.size > maxOriginalSizeMB * 1024 * 1024) { + const msg = `ফাইলের আকার ${maxOriginalSizeMB}MB এর চেয়ে কম হতে হবে।`; + error.value = msg; + options.onError?.(msg); + + return null; + } + + originalSizeFormatted.value = formatFileSize(rawFile.size); + cleanupPreview(); + + try { + isCompressing.value = true; + const compressed = await compressImage( + rawFile, + options.compressOptions, + ); + compressedSizeFormatted.value = formatFileSize(compressed.size); + + file.value = compressed; + previewUrl.value = URL.createObjectURL(compressed); + + options.onCompressed?.(compressed); + + return compressed; + } catch (err: any) { + console.error('[useImageUpload] Compression error:', err); + // Fall back to original file if compression fails unexpectedly + file.value = rawFile; + previewUrl.value = URL.createObjectURL(rawFile); + + return rawFile; + } finally { + isCompressing.value = false; + } + }; + + const handleFileInput = async (event: Event): Promise => { + const target = event.target as HTMLInputElement; + + if (!target.files || target.files.length === 0) { + return null; + } + + const selected = target.files[0]; + const result = await processFile(selected); + // Reset input value so re-selecting the same file triggers change + target.value = ''; + + return result; + }; + + onUnmounted(() => { + cleanupPreview(); + }); + + return { + file, + previewUrl, + isCompressing, + error, + originalSizeFormatted, + compressedSizeFormatted, + processFile, + handleFileInput, + clear, + }; +} diff --git a/resources/js/pages/Forum/Create.vue b/resources/js/pages/Forum/Create.vue index cc3aca08..d2df52ea 100644 --- a/resources/js/pages/Forum/Create.vue +++ b/resources/js/pages/Forum/Create.vue @@ -12,6 +12,7 @@ import { Loader2, } from 'lucide-vue-next'; import { computed, ref, watch } from 'vue'; +import { compressImage } from '@/lib/imageCompression'; interface NodeItem { id: number; @@ -85,13 +86,41 @@ const setCurriculum = (curriculum: 'hsc' | 'ssc') => { form.node_id = ''; }; +const isCompressingImage = ref(false); + +const processSelectedImage = async (rawFile: File) => { + try { + isCompressingImage.value = true; + const compressed = await compressImage(rawFile, { + maxWidth: 2048, + maxHeight: 2048, + quality: 0.85, + }); + form.image = compressed; + + if (imagePreview.value) { + URL.revokeObjectURL(imagePreview.value); + } + + imagePreview.value = URL.createObjectURL(compressed); + } catch { + form.image = rawFile; + + if (imagePreview.value) { + URL.revokeObjectURL(imagePreview.value); + } + + imagePreview.value = URL.createObjectURL(rawFile); + } finally { + isCompressingImage.value = false; + } +}; + const handleFileChange = (e: Event) => { const target = e.target as HTMLInputElement; if (target.files && target.files[0]) { - const file = target.files[0]; - form.image = file; - imagePreview.value = URL.createObjectURL(file); + processSelectedImage(target.files[0]); } }; @@ -100,8 +129,7 @@ const handleFileDrop = (e: DragEvent) => { const file = e.dataTransfer.files[0]; if (file.type.startsWith('image/')) { - form.image = file; - imagePreview.value = URL.createObjectURL(file); + processSelectedImage(file); } } }; @@ -362,7 +390,7 @@ const submit = () => { > Attach Image (Optional, Max 5MB)(Optional, Max 20MB, auto-optimized) @@ -372,6 +400,7 @@ const submit = () => { type="file" id="post-image-file" accept="image/jpeg,image/png,image/jpg,image/webp" + :disabled="isCompressingImage" @click=" (e) => ((e.target as HTMLInputElement).value = '') @@ -385,19 +414,32 @@ const submit = () => { @dragenter.prevent @drop.prevent="handleFileDrop" class="flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-200 bg-slate-50/50 p-6 text-center transition hover:border-indigo-500 hover:bg-indigo-50/20 dark:border-gray-800 dark:bg-gray-900/50 dark:hover:border-indigo-500" + :class="{ + 'cursor-not-allowed opacity-60': + isCompressingImage, + }" > + - Click or drag to upload an image + {{ + isCompressingImage + ? 'Optimizing image...' + : 'Click or drag to upload an image' + }} - JPG, PNG, WEBP up to 5MB + JPG, PNG, WEBP up to 20MB (auto-optimized)
diff --git a/resources/js/pages/Forum/Show.vue b/resources/js/pages/Forum/Show.vue index 66465bb7..beb02862 100644 --- a/resources/js/pages/Forum/Show.vue +++ b/resources/js/pages/Forum/Show.vue @@ -31,6 +31,7 @@ import ImageViewerModal from '@/components/ImageViewerModal.vue'; import Pagination from '@/components/Pagination.vue'; import UserListItem from '@/components/UserListItem.vue'; import VerifiedBadge from '@/components/VerifiedBadge.vue'; +import { compressImage } from '@/lib/imageCompression'; import { useAuth } from '@/lib/useAuth'; import { getCsrfToken } from '@/lib/useCsrf'; import { formatTimeAgo } from '@/lib/useDate'; @@ -374,14 +375,39 @@ const answerForm = useForm({ const answerImagePreview = ref(null); const answerFileInputRef = ref(null); +const isCompressingAnswerImage = ref(false); -const handleAnswerFileChange = (e: Event) => { +const handleAnswerFileChange = async (e: Event) => { const target = e.target as HTMLInputElement; if (target.files && target.files[0]) { - const file = target.files[0]; - answerForm.image = file; - answerImagePreview.value = URL.createObjectURL(file); + const rawFile = target.files[0]; + + try { + isCompressingAnswerImage.value = true; + const compressed = await compressImage(rawFile, { + maxWidth: 2048, + maxHeight: 2048, + quality: 0.85, + }); + answerForm.image = compressed; + + if (answerImagePreview.value) { + URL.revokeObjectURL(answerImagePreview.value); + } + + answerImagePreview.value = URL.createObjectURL(compressed); + } catch { + answerForm.image = rawFile; + + if (answerImagePreview.value) { + URL.revokeObjectURL(answerImagePreview.value); + } + + answerImagePreview.value = URL.createObjectURL(rawFile); + } finally { + isCompressingAnswerImage.value = false; + } } }; @@ -459,13 +485,39 @@ const cancelReply = () => { } }; -const handleReplyFileChange = (e: Event) => { +const isCompressingReplyImage = ref(false); + +const handleReplyFileChange = async (e: Event) => { const target = e.target as HTMLInputElement; if (target.files && target.files[0]) { - const file = target.files[0]; - replyForm.image = file; - replyImagePreview.value = URL.createObjectURL(file); + const rawFile = target.files[0]; + + try { + isCompressingReplyImage.value = true; + const compressed = await compressImage(rawFile, { + maxWidth: 2048, + maxHeight: 2048, + quality: 0.85, + }); + replyForm.image = compressed; + + if (replyImagePreview.value) { + URL.revokeObjectURL(replyImagePreview.value); + } + + replyImagePreview.value = URL.createObjectURL(compressed); + } catch { + replyForm.image = rawFile; + + if (replyImagePreview.value) { + URL.revokeObjectURL(replyImagePreview.value); + } + + replyImagePreview.value = URL.createObjectURL(rawFile); + } finally { + isCompressingReplyImage.value = false; + } } }; diff --git a/resources/js/pages/Profile.vue b/resources/js/pages/Profile.vue index 1b1ea113..18a7299b 100644 --- a/resources/js/pages/Profile.vue +++ b/resources/js/pages/Profile.vue @@ -13,6 +13,7 @@ import { LifeBuoy, } from 'lucide-vue-next'; import { computed, ref } from 'vue'; +import { compressImage } from '@/lib/imageCompression'; const props = defineProps({ user: Object, @@ -27,6 +28,7 @@ const isUnverified = computed(() => { const showAdvancedSettings = ref(false); const showConfirmModal = ref(false); +const isCompressingAvatar = ref(false); const form = useForm({ _method: 'PUT', @@ -41,6 +43,27 @@ const form = useForm({ receive_emails: user.value?.receive_emails ?? true, }); +const handleAvatarSelect = async (event: Event) => { + const input = event.target as HTMLInputElement; + + if (input.files && input.files[0]) { + const raw = input.files[0]; + + try { + isCompressingAvatar.value = true; + form.file = await compressImage(raw, { + maxWidth: 512, + maxHeight: 512, + quality: 0.85, + }); + } catch { + form.file = raw; + } finally { + isCompressingAvatar.value = false; + } + } +}; + const handleEmailToggle = () => { if (form.receive_emails) { showConfirmModal.value = true; @@ -320,19 +343,27 @@ const submitForm = () => {
-
+
{{ user?.name?.charAt(0)?.toUpperCase() }}
+
+ +
@@ -345,13 +376,9 @@ const submitForm = () => { - Supports PNG, JPG, or WEBP up to 2MB. + {{ + isCompressingAvatar + ? 'Optimizing avatar...' + : 'Supports PNG, JPG, or WEBP up to 20MB (auto-optimized).' + }}

(null); const fileInputRef = ref(null); +const isCompressing = ref(false); -const handleImageChange = (e: Event) => { +const handleImageChange = async (e: Event) => { const target = e.target as HTMLInputElement; - const file = target.files?.[0]; + const rawFile = target.files?.[0]; - if (file) { - form.image = file; + if (rawFile) { + try { + isCompressing.value = true; + const compressed = await compressImage(rawFile, { + maxWidth: 512, + maxHeight: 512, + quality: 0.85, + }); + form.image = compressed; - if (previewUrl.value) { - URL.revokeObjectURL(previewUrl.value); - } + if (previewUrl.value) { + URL.revokeObjectURL(previewUrl.value); + } + + previewUrl.value = URL.createObjectURL(compressed); + } catch { + form.image = rawFile; - previewUrl.value = URL.createObjectURL(file); + if (previewUrl.value) { + URL.revokeObjectURL(previewUrl.value); + } + + previewUrl.value = URL.createObjectURL(rawFile); + } finally { + isCompressing.value = false; + } } }; @@ -145,8 +165,16 @@ const submit = () => {

+
+ +
{ { v-if="previewUrl" type="button" @click="removeCustomImage" - class="cursor-pointer text-[11px] font-medium text-rose-500 hover:text-rose-600 hover:underline dark:text-rose-400" + :disabled="isCompressing" + class="cursor-pointer text-[11px] font-medium text-rose-500 hover:text-rose-600 hover:underline disabled:opacity-50 dark:text-rose-400" > Reset @@ -216,7 +249,7 @@ const submit = () => { From ef089a10a2ae92f7aedceac21938148e3140d8b9 Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 2 Sep 2026 16:33:44 +0600 Subject: [PATCH 2/2] fix(upload): address code review feedback on image compression and guards - Remove image/gif from client allowed types and accept attributes - Enforce 5MB limit on compressed image results across forms - Fix imageCompression extension mapping and size comparison - Guard actions and disable submit buttons while compression is running --- resources/js/components/ImageUpload.vue | 2 +- .../js/components/admin/BulkImageModal.vue | 65 +++++++++-- .../components/admin/CreateResourceModal.vue | 38 +++++-- resources/js/lib/imageCompression.ts | 9 +- resources/js/lib/useImageUpload.ts | 55 ++++++---- resources/js/pages/Forum/Create.vue | 63 ++++++++--- resources/js/pages/Forum/Show.vue | 101 +++++++++++++----- resources/js/pages/Profile.vue | 41 +++++-- resources/js/pages/auth/Onboarding.vue | 53 ++++++--- 9 files changed, 327 insertions(+), 100 deletions(-) diff --git a/resources/js/components/ImageUpload.vue b/resources/js/components/ImageUpload.vue index 605d1199..4560ae4b 100644 --- a/resources/js/components/ImageUpload.vue +++ b/resources/js/components/ImageUpload.vue @@ -117,7 +117,7 @@ watch( { }); }); +const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; + const addFiles = (files: FileList | File[]) => { + if (isSaving.value || isCompressing.value) { + return; + } + fileLimitError.value = ''; const imageFiles = Array.from(files).filter((file) => - file.type.startsWith('image/'), + ALLOWED_IMAGE_TYPES.includes(file.type), ); if (imageFiles.length === 0) { @@ -81,6 +88,10 @@ const addFiles = (files: FileList | File[]) => { }; const handleFileSelect = (event: Event) => { + if (isSaving.value || isCompressing.value) { + return; + } + const input = event.target as HTMLInputElement; if (input.files?.length) { @@ -92,12 +103,20 @@ const handleFileSelect = (event: Event) => { const handleDrop = (event: DragEvent) => { isDragging.value = false; + if (isSaving.value || isCompressing.value) { + return; + } + if (event.dataTransfer?.files?.length) { addFiles(event.dataTransfer.files); } }; const removeFile = (index: number) => { + if (isSaving.value || isCompressing.value) { + return; + } + if (selectedFiles.value[index]) { URL.revokeObjectURL(selectedFiles.value[index].previewUrl); selectedFiles.value.splice(index, 1); @@ -106,6 +125,10 @@ const removeFile = (index: number) => { }; const clearAll = () => { + if (isSaving.value || isCompressing.value) { + return; + } + selectedFiles.value.forEach((item) => URL.revokeObjectURL(item.previewUrl)); selectedFiles.value = []; fileLimitError.value = ''; @@ -118,7 +141,7 @@ const clearAll = () => { }; const handleClose = () => { - if (isSaving.value) { + if (isSaving.value || isCompressing.value) { return; } @@ -129,7 +152,7 @@ const handleClose = () => { watch( () => props.isOpen, (open) => { - if (!open) { + if (!open && !isSaving.value && !isCompressing.value) { clearAll(); } }, @@ -140,7 +163,11 @@ onUnmounted(() => { }); const submitForm = async () => { - if (selectedFiles.value.length === 0 || isSaving.value) { + if ( + selectedFiles.value.length === 0 || + isSaving.value || + isCompressing.value + ) { return; } @@ -164,6 +191,23 @@ const submitForm = async () => { isCompressing.value = false; } + // Verify modal was not closed/cancelled during compression + if (!props.isOpen || selectedFiles.value.length === 0) { + isSaving.value = false; + + return; + } + + // Post-compression 5MB check + const oversized = filesToUpload.find((f) => f.size > 5 * 1024 * 1024); + + if (oversized) { + errorMessage.value = `ছবি "${oversized.name}" অপটিমাইজ করার পরও ৫MB এর বেশি। অনুগ্রহ করে ছোট ছবি নির্বাচন করুন।`; + isSaving.value = false; + + return; + } + uploadProgress.value = 0; isProcessingServer.value = false; @@ -272,13 +316,18 @@ const submitForm = async () => { type="file" id="modal-bulk-image-upload" multiple - accept="image/jpeg,image/png,image/jpg,image/webp,image/gif" + accept="image/jpeg,image/png,image/jpg,image/webp" class="hidden" + :disabled="isSaving || isCompressing" @change="handleFileSelect" />