1import Modal from '/scripts/Modals/BasicModalMixin.js'; 2 3export default { 4 name: 'GroupImageModal', 5 6 components: { 7 Modal, 8 }, 9 10 props: ['visible', 'groupId', 'healthyChurchApi', 'healthyChurchSmallGroupsApi', 'authHeaders', 'rockDomain', 'currentImageUrl'], 11 12 emits: ['close', 'uploaded'], 13 14 template: ` 15 <Modal 16 v-show="visible" 17 v-on:close="$emit('close')" 18 class="group-image-modal" 19 > 20 <template v-slot:header> 21 <!-- Error Alert --> 22 <div v-if="errorMessage" class="error-alert"> 23 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" style="flex-shrink: 0; min-width: 24px; min-height: 24px;"> 24 <mask id="mask0_2694_13268" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> 25 <rect width="24" height="24" fill="#D9D9D9"/> 26 </mask> 27 <g mask="url(#mask0_2694_13268)"> 28 <path d="M1 21L12 2L23 21H1ZM12 18C12.2833 18 12.5208 17.9042 12.7125 17.7125C12.9042 17.5208 13 17.2833 13 17C13 16.7167 12.9042 16.4792 12.7125 16.2875C12.5208 16.0958 12.2833 16 12 16C11.7167 16 11.4792 16.0958 11.2875 16.2875C11.0958 16.4792 11 16.7167 11 17C11 17.2833 11.0958 17.5208 11.2875 17.7125C11.4792 17.9042 11.7167 18 12 18ZM11 15H13V10H11V15Z" fill="white"/> 29 </g> 30 </svg> 31 <span>{{ errorMessage }}</span> 32 </div> 33 34 <div class="group-image-header"> 35 <h3>Group Image</h3> 36 </div> 37 </template> 38 39 <template v-slot:body> 40 <div class="group-image-body"> 41 <!-- Current image display --> 42 <div v-if="currentImageUrl && !selectedFile" class="current-image-container"> 43 <img :src="currentImageUrl" alt="Current Group Image" class="current-image"> 44 <p class="requirements-text" v-if="!selectedFile">Image Size Max: 1920x1080, < 1MB</p> 45 </div> 46 47 <!-- Image preview (only show after cropping is done) --> 48 <div v-if="imagePreview && showUploadButton" class="img-div"> 49 <img :src="imagePreview" alt="Cropped Preview" class="preview-image"> 50 </div> 51 52 <!-- Cropper container (visible during cropping) --> 53 <div id="group-cropme-container" class="modal-body mb-2" v-show="showCropButton"> 54 <div id="group-imgModal-cropme-wrp" class="justify-content-center"> 55 <div id="group-imgModal-cropme" class=""></div> 56 </div> 57 </div> 58 59 <!-- File upload --> 60 <div v-if="!selectedFile" class="modal-footer" :class="{ 'has-error': errorMessage }"> 61 <label for="groupFileUpload" class="btn">Choose File</label> 62 <input style="display: none !important;" class="file-upload-btn" type="file" id="groupFileUpload" accept="image/*" 63 v-on:change="handleFileSelect" /> 64 <button type="button" class="btn invert btn-info" v-on:click="$emit('close')">Cancel</button> 65 </div> 66 67 <!-- Action buttons --> 68 <div v-if="selectedFile" class="modal-footer justify-content-center"> 69 <button v-if="showCropButton" id="groupApplyCrop" type="button" class="btn btn-info" 70 v-on:click="applyCrop">Crop</button> 71 <button v-if="showUploadButton" class="btn btn-primary" v-on:click="handleUpload" 72 :disabled="isUploading">{{ isUploading ? 'Uploading...' : 'Save Photo' }}</button> 73 <button type="button" class="btn invert btn-info" v-on:click="$emit('close')">Cancel</button> 74 </div> 75 </div> 76 </template> 77 </Modal> 78 `, 79 80 data() { 81 return { 82 selectedFile: null, 83 imagePreview: null,
84 showCropButton: false, 85 showUploadButton: false, 86 isUploading: false, 87 cropmeInstance: null, 88 cropmeLoaded: false, 89 imgHolder: null, 90 errorMessage: null, 91 }; 92 }, 93 94 watch: { 95 visible(newVal, oldVal) { 96 if (!oldVal && newVal) { 97 // Modal is opening - reset everything first 98 this.resetState(); 99 document.body.classList.add('modal-open'); 100 } else if (oldVal && !newVal) { 101 // Modal is closing - cleanup 102 this.cleanup(); 103 document.body.classList.remove('modal-open'); 104 } 105 }, 106 }, 107 108 beforeUnmount() { 109 this.cleanup(); 110 document.body.classList.remove('modal-open'); 111 }, 112 113 methods: { 114 // Load the correct cropme library (lowercase 'c') - same as your working profile 115 async loadCropMe() { 116 if (this.cropmeLoaded && window.Cropme) return; 117 118 try { 119 // Load CSS 120 if (!document.querySelector('link[href*="cropme"]')) { 121 const cssLink = document.createElement('link'); 122 cssLink.rel = 'stylesheet'; 123 cssLink.href = 'https://cdn.jsdelivr.net/npm/cropme@latest/dist/cropme.min.css'; 124 document.head.appendChild(cssLink); 125 } 126 127 // Load JS - using the SAME URL as your working profile code 128 if (!window.Cropme) { 129 await new Promise((resolve, reject) => { 130 const script = document.createElement('script'); 131 script.src = 'https://cdn.jsdelivr.net/npm/cropme@latest/dist/cropme.min.js'; 132 script.onload = () => { 133 // Wait longer and check multiple times 134 let attempts = 0; 135 const checkCropme = () => { 136 attempts++; 137 if (window.Cropme) { 138 console.log('Cropme loaded successfully after', attempts, 'attempts'); 139 resolve(); 140 } else if (attempts < 10) { 141 setTimeout(checkCropme, 100); 142 } else { 143 reject(new Error('Cropme library loaded but not available after 10 attempts')); 144 } 145 }; 146 checkCropme(); 147 }; 148 script.onerror = () => reject(new Error('Failed to load cropme script')); 149 document.head.appendChild(script); 150 }); 151 } 152 153 this.cropmeLoaded = true; 154 } catch (error) { 155 console.error('Error loading cropme:', error); 156 throw error; 157 } 158 }, 159 160 async handleFileSelect(event) { 161 // Clear any previous error messages 162 this.errorMessage = null; 163 164 const file = event.target.files[0]; 165 if (!file || !file.type.match('image.*')) { 166 this.errorMessage = 'Please select a valid image file (JPG, PNG, GIF, etc.)'; 167 return; 168 } 169 170 // Check file size (1MB = 1048576 bytes) 171 if (file.size > 1048576) { 172 this.errorMessage = 'Image file size is too large. Please choose an image smaller than 1MB'; 173 event.target.value = ''; // Clear the input 174 return; 175 } 176 177 // Check image dimensions 178 const isValidDimensions = await this.validateImageDimensions(file); 179 if (!isValidDimensions) { 180 event.target.value = ''; // Clear the input 181 return; 182 } 183 184 this.selectedFile = file; 185 186 try { 187 // Load cropme library 188 await this.loadCropMe(); 189 190 // Read file and show preview 191 await this.readFileFromInput(file); 192 193 // Initialize cropper 194 await this.initializeCropper(); 195 196 } catch (error) { 197 console.error('Error initializing cropper:', error); 198 this.errorMessage = 'Error loading image editor. Please try again or choose a different image'; 199 } 200 }, 201 202 validateImageDimensions(file) { 203 return new Promise((resolve) => { 204 const img = new Image(); 205 img.onload = () => { 206 if (img.width > 1920 || img.height > 1080) { 207 this.errorMessage = `Image dimensions (${img.width}Ã${img.height}) are too large. Maximum allowed: 1920Ã1080 pixels`; 208 resolve(false); 209 } else { 210 resolve(true); 211 } 212 }; 213 img.onerror = () => { 214 this.errorMessage = 'Unable to read image file. Please try a different image'; 215 resolve(false); 216 }; 217 img.src = URL.createObjectURL(file); 218 }); 219 }, 220 221 readFileFromInput(file) { 222 return new Promise((resolve, reject) => { 223 if (!file) { 224 reject(new Error('No file provided')); 225 return; 226 } 227 228 const reader = new FileReader(); 229 230 // Create new image holder for each file 231 this.imgHolder = new Image(); 232 this.imgHolder.onload = () => { 233 // Show preview 234 this.imagePreview = this.imgHolder.src; 235 resolve(); 236 }; 237 this.imgHolder.onerror = reject; 238 239 reader.onload = (e) => { 240 this.imgHolder.src = e.target.result; 241 }; 242 243 reader.onerror = reject;
244 reader.readAsDataURL(file); 245 }); 246 }, 247 248 async initializeCropper() { 249 // Clean up any existing instance first 250 if (this.cropmeInstance) { 251 try { 252 this.cropmeInstance.destroy(); 253 } catch (e) { 254 console.warn('Error destroying previous cropme instance:', e); 255 } 256 this.cropmeInstance = null; 257 } 258 259 const container = document.getElementById('group-cropme-container'); 260 const cropmeDiv = document.getElementById('group-imgModal-cropme'); 261 262 if (!container || !cropmeDiv) { 263 throw new Error('Cropper container not found'); 264 } 265 266 // Clear the cropper div content 267 cropmeDiv.innerHTML = ''; 268 269 // Show the cropper container 270 container.style.display = 'block'; 271 272 // Wait a bit for DOM updates 273 await new Promise(resolve => setTimeout(resolve, 100)); 274 275 // Calculate responsive container dimensions with max-width 378px 276 const maxWidth = 378; 277 const targetAspectRatio = 16 / 9; // 1.78 278 279 // Get the modal width to calculate responsive sizing 280 const modalElement = container.closest('.modal'); 281 const modalWidth = modalElement ? modalElement.offsetWidth : 660; 282 const availableWidth = Math.min(modalWidth - 40, maxWidth); // 20px padding each side, max 378px 283 284 const containerWidth = availableWidth; 285 const containerHeight = containerWidth / targetAspectRatio; 286 287 // Viewport fills most of the container (leaving some padding) 288 const viewportWidth = containerWidth - 20; // 10px padding on each side 289 const viewportHeight = viewportWidth / targetAspectRatio; 290 291 // Update options for group image dimensions 292 const cropmeOptions = { 293 container: { width: containerWidth, height: containerHeight }, 294 viewport: { 295 width: viewportWidth, // Full container width 296 height: viewportHeight, // Calculated to maintain 16:9 ratio 297 type: 'rectangle', 298 border: { 299 enable: true, 300 width: 2, 301 color: '#fff' 302 } 303 }, 304 zoom: { enable: true, mouseWheel: true, slider: false }, 305 rotation: { slider: false, enable: false }, 306 transformOrigin: 'viewport', 307 customClass: 'group-image-cropper' 308 }; 309 310 try { 311 // Initialize Cropme (note the capital C) 312 this.cropmeInstance = new Cropme(cropmeDiv, cropmeOptions); 313 314 // Wait for instance to be ready before binding 315 await new Promise(resolve => setTimeout(resolve, 50)); 316 317 // Bind the image 318 this.cropmeInstance.bind({ url: this.imgHolder.src }); 319 320 this.showCropButton = true; 321 this.showUploadButton = false; 322 323 console.log('Cropper initialized successfully'); 324 } catch (error) { 325 console.error('Error initializing cropme instance:', error); 326 throw error; 327 } 328 }, 329 330 applyCrop() { 331 if (!this.cropmeInstance) { 332 console.error('No cropme instance available'); 333 return; 334 } 335 336 this.cropmeInstance.crop({ 337 width: 2800, // Target group image width 338 }).then((result) => { 339 // Update preview with cropped image 340 this.imagePreview = result; 341 342 // Hide cropper, show upload button 343 document.getElementById('group-cropme-container').style.display = 'none'; 344 this.showCropButton = false; 345 this.showUploadButton = true; 346 347 // Clean up cropper instance 348 if (this.cropmeInstance) { 349 try { 350 this.cropmeInstance.destroy(); 351 } catch (e) { 352 console.warn('Error destroying cropme instance after crop:', e); 353 } 354 this.cropmeInstance = null; 355 } 356 }).catch((error) => { 357 console.error('Error cropping image:', error); 358 this.errorMessage = 'Error cropping image. Please try again or choose a different image'; 359 }); 360 }, 361 362 async handleUpload() { 363 if (!this.imagePreview) return; 364 365 this.isUploading = true; 366
367 try { 368 // Convert data URL to blob 369 const response = await fetch(this.imagePreview); 370 const blob = await response.blob(); 371 372 // Check if we need to compress the image 373 let finalBlob = blob; 374 if (blob.size > 1048576) { // 1MB limit 375 finalBlob = await this.compressImage(blob); 376 } 377 378 // Create file from blob 379 const file = new File([finalBlob], this.selectedFile.name, { 380 type: 'image/jpeg', 381 lastModified: Date.now(), 382 }); 383 384 const formData = new FormData(); 385 formData.append('file', file, file.name); 386 387 await axios.post( 388 `${this.healthyChurchApi}/api/v4/smallgroups/${this.groupId}/photo`, 389 formData, 390 this.authHeaders 391 ); 392 393 // Success! Emit uploaded event 394 this.$emit('uploaded'); 395 396 // Reset state completely before closing 397 this.resetState(); 398 399 // Close the modal 400 this.$emit('close'); 401 402 } catch (error) { 403 console.error('Error uploading image:', error); 404 this.errorMessage = 'Failed to upload image. Please check your connection and try again'; 405 } finally { 406 this.isUploading = false; 407 } 408 }, 409 410 cancelChanges() { 411 this.cleanup(); 412 this.resetState(); 413 }, 414 415 resetState() { 416 // Clean up cropper instance first 417 this.cleanup(); 418 419 // Reset all reactive data properties 420 this.selectedFile = null; 421 this.imagePreview = null; 422 this.showCropButton = false; 423 this.showUploadButton = false; 424 this.isUploading = false; 425 this.imgHolder = null; 426 this.errorMessage = null; 427 428 // Reset file input 429 const fileInput = document.getElementById('groupFileUpload'); 430 if (fileInput) { 431 fileInput.value = ''; 432 } 433 434 // Hide and clear cropper container 435 const container = document.getElementById('group-cropme-container'); 436 if (container) { 437 container.style.display = 'none'; 438 } 439 440 const cropmeDiv = document.getElementById('group-imgModal-cropme'); 441 if (cropmeDiv) { 442 cropmeDiv.innerHTML = ''; 443 } 444 }, 445 446 compressImage(blob, quality = 0.8) { 447 return new Promise((resolve) => { 448 const canvas = document.createElement('canvas'); 449 const ctx = canvas.getContext('2d'); 450 const img = new Image(); 451 452 img.onload = () => { 453 // Calculate dimensions to fit within 1920x1080 while maintaining aspect ratio 454 let { width, height } = img; 455 const maxWidth = 1920; 456 const maxHeight = 1080; 457 458 if (width > maxWidth || height > maxHeight) { 459 const aspectRatio = width / height; 460 461 if (width / maxWidth > height / maxHeight) {
462 // Width is the limiting factor 463 width = maxWidth; 464 height = width / aspectRatio; 465 } else { 466 // Height is the limiting factor 467 height = maxHeight; 468 width = height * aspectRatio; 469 } 470 } 471 472 // Set canvas dimensions to calculated dimensions 473 canvas.width = width; 474 canvas.height = height; 475 476 // Draw image to canvas with new dimensions 477 ctx.drawImage(img, 0, 0, width, height); 478 479 // Convert to blob with compression 480 canvas.toBlob((compressedBlob) => { 481 // If still too large in file size, try lower quality 482 if (compressedBlob.size > 1048576 && quality > 0.3) { 483 resolve(this.compressImage(blob, quality - 0.1)); 484 } else { 485 resolve(compressedBlob); 486 } 487 }, 'image/jpeg', quality); 488 }; 489 490 img.src = URL.createObjectURL(blob); 491 }); 492 }, 493 494 cleanup() { 495 // Clean up cropper instance 496 if (this.cropmeInstance) { 497 try { 498 this.cropmeInstance.destroy(); 499 } catch (e) { 500 console.warn('Error destroying cropme instance during cleanup:', e); 501 } 502 this.cropmeInstance = null; 503 } 504 505 // Clean up any created URLs 506 if (this.imagePreview && this.imagePreview.startsWith('blob:')) { 507 try { 508 URL.revokeObjectURL(this.imagePreview); 509 } catch (e) { 510 console.warn('Error revoking object URL:', e); 511 } 512 } 513 514 // Clear the cropper div 515 const cropmeDiv = document.getElementById('group-imgModal-cropme'); 516 if (cropmeDiv) { 517 cropmeDiv.innerHTML = ''; 518 } 519 }, 520 }, 521};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.