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..4560ae4b --- /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..be2c40ac 100644 --- a/resources/js/components/admin/BulkImageModal.vue +++ b/resources/js/components/admin/BulkImageModal.vue @@ -7,9 +7,11 @@ import { Hash, Loader2, AlertCircle, + X, } 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 +30,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); @@ -52,10 +56,16 @@ const processedTitles = computed(() => { }); }); +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) { @@ -78,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) { @@ -89,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); @@ -103,17 +125,23 @@ const removeFile = (index: number) => { }; const clearAll = () => { + if (isSaving.value || isCompressing.value) { + return; + } + selectedFiles.value.forEach((item) => URL.revokeObjectURL(item.previewUrl)); selectedFiles.value = []; fileLimitError.value = ''; errorMessage.value = ''; isSaving.value = false; + isCompressing.value = false; + compressionStatusText.value = ''; uploadProgress.value = null; isProcessingServer.value = false; }; const handleClose = () => { - if (isSaving.value) { + if (isSaving.value || isCompressing.value) { return; } @@ -124,7 +152,7 @@ const handleClose = () => { watch( () => props.isOpen, (open) => { - if (!open) { + if (!open && !isSaving.value && !isCompressing.value) { clearAll(); } }, @@ -134,22 +162,61 @@ onUnmounted(() => { clearAll(); }); -const submitForm = () => { - if (selectedFiles.value.length === 0 || isSaving.value) { +const submitForm = async () => { + if ( + selectedFiles.value.length === 0 || + isSaving.value || + isCompressing.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; + } + + // 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; - 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, }; @@ -249,13 +316,18 @@ const submitForm = () => { 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" />