The smokesignal.events web application
at main 1392 lines 60 kB view raw
1{% from "form_include.html" import text_input %} 2<div x-data="eventForm()" x-cloak> 3 <!-- Success Message --> 4 <template x-if="submitted"> 5 <article class="message is-success"> 6 <div class="message-header"> 7 <p x-text="isEditMode ? 'Event updated!' : 'Event created!'"></p> 8 <button class="delete" @click="submitted = false"></button> 9 </div> 10 <div class="message-body" x-show="!isEditMode"> 11 <p class="buttons"> 12 <a class="button" :href="eventUrl"> 13 <span class="icon"> 14 <i class="fas fa-file"></i> 15 </span> 16 <span>View Event</span> 17 </a> 18 <button type="button" @click="resetForm()" class="button"> 19 <span class="icon"> 20 <i class="fas fa-plus"></i> 21 </span> 22 <span>Create Another Event</span> 23 </button> 24 </p> 25 </div> 26 </article> 27 </template> 28 29 <!-- Error Message --> 30 <template x-if="errorMessage"> 31 <article class="message is-danger"> 32 <div class="message-header"> 33 <p>Error</p> 34 <button class="delete" @click="errorMessage = null"></button> 35 </div> 36 <div class="message-body"> 37 <p x-text="errorMessage"></p> 38 </div> 39 </article> 40 </template> 41 42 <!-- Event Form --> 43 <form @submit.prevent="submitForm()" x-show="!submitted || isEditMode" class="my-5" novalidate> 44 45 <!-- Basic Info Box --> 46 <div class="box content pb-0"> 47 <!-- Name --> 48 <div class="field"> 49 <label class="label" for="eventName">Name (required)</label> 50 <div class="control"> 51 <input 52 type="text" 53 class="input" 54 id="eventName" 55 x-model="formData.name" 56 minlength="10" 57 maxlength="500" 58 placeholder="My Awesome Event" 59 required> 60 </div> 61 <p class="help">Must be at least 10 characters and no more than 500 characters.</p> 62 </div> 63 64 <!-- Description with Preview --> 65 <div class="field"> 66 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;"> 67 <label class="label" for="eventDescription" style="margin-bottom: 0;">Description (required)</label> 68 <div class="buttons has-addons" style="margin-bottom: 0;"> 69 <button 70 type="button" 71 class="button is-small" 72 :class="{ 'is-primary': !showDescriptionPreview }" 73 @click="showDescriptionPreview = false"> 74 <span class="icon is-small"> 75 <i class="fas fa-edit"></i> 76 </span> 77 <span>Edit</span> 78 </button> 79 <button 80 type="button" 81 class="button is-small" 82 :class="{ 'is-primary': showDescriptionPreview }" 83 @click="toggleDescriptionPreview()"> 84 <span class="icon is-small"> 85 <i class="fas fa-eye"></i> 86 </span> 87 <span>Preview</span> 88 </button> 89 </div> 90 </div> 91 <div class="control"> 92 <!-- Edit Mode --> 93 <textarea 94 x-show="!showDescriptionPreview" 95 class="textarea" 96 id="eventDescription" 97 x-model="formData.description" 98 maxlength="3000" 99 rows="10" 100 placeholder="A helpful, brief description of the event." 101 required></textarea> 102 103 <!-- Preview Mode --> 104 <div 105 x-show="showDescriptionPreview" 106 style="border: 1px solid #dbdbdb; border-radius: 4px; padding: 0.75rem; min-height: 200px;"> 107 <div x-show="loadingPreview" class="has-text-centered"> 108 <span class="icon"> 109 <i class="fas fa-spinner fa-pulse"></i> 110 </span> 111 <span>Loading preview...</span> 112 </div> 113 <div x-show="!loadingPreview && descriptionPreviewHtml" x-html="descriptionPreviewHtml" style="white-space: pre-wrap; word-break: break-word;"></div> 114 <div x-show="!loadingPreview && !descriptionPreviewHtml" class="has-text-grey-light has-text-centered"> 115 <p>Preview will appear here</p> 116 <p class="is-size-7">Mentions, links, and hashtags will be rendered</p> 117 </div> 118 </div> 119 </div> 120 <p class="help">Must be at least 10 characters and no more than 3000 characters. Use @mentions, #hashtags, and https:// links.</p> 121 </div> 122 123 <!-- Header Image Upload --> 124 <div class="field"> 125 <label class="label">Header Image (optional)</label> 126 <div class="control"> 127 <div class="file"> 128 <label class="file-label"> 129 <input 130 class="file-input" 131 type="file" 132 accept="image/png,image/jpeg" 133 @change="handleHeaderImageSelect($event)"> 134 <span class="file-cta"> 135 <span class="icon"><i class="fas fa-upload"></i></span> 136 <span class="file-label">Choose header image...</span> 137 </span> 138 </label> 139 </div> 140 </div> 141 <p class="help">Upload a 3:1 banner image for your event (max 3MB). Crop to the desired area before uploading.</p> 142 143 <!-- Cropper Canvas --> 144 <canvas id="headerCanvas" x-show="showCropper" style="display: none; max-width: 100%; margin-top: 1rem; border: 1px solid #dbdbdb;"></canvas> 145 <button 146 type="button" 147 x-show="showCropper" 148 @click="uploadCroppedHeader()" 149 class="button is-primary mt-2" 150 style="display: none;" 151 :disabled="uploading"> 152 <span x-text="uploading ? 'Uploading...' : 'Upload Cropped Header'"></span> 153 </button> 154 155 <!-- Header Preview --> 156 <div x-show="formData.headerCid" style="display: none; margin-top: 10px;"> 157 <img :src="headerPreviewUrl" style="max-width: 400px; aspect-ratio: 3/1; object-fit: cover; border: 1px solid #dbdbdb;"> 158 <br> 159 <button type="button" class="button is-small is-danger mt-2" @click="removeHeader()">Remove Header</button> 160 </div> 161 </div> 162 163 <!-- Thumbnail Image Upload --> 164 <div class="field"> 165 <label class="label">Thumbnail Image (optional)</label> 166 <div class="control"> 167 <div class="file"> 168 <label class="file-label"> 169 <input 170 class="file-input" 171 type="file" 172 id="thumbnailInput" 173 accept="image/png,image/jpeg" 174 @change="handleThumbnailImageSelect($event)"> 175 <span class="file-cta"> 176 <span class="icon"><i class="fas fa-upload"></i></span> 177 <span class="file-label">Choose thumbnail image...</span> 178 </span> 179 </label> 180 </div> 181 </div> 182 <p class="help">Upload a 1:1 square thumbnail (512x512 to 1024x1024) for event cards and social sharing.</p> 183 184 <!-- Thumbnail Cropper Canvas --> 185 <canvas id="thumbnailCanvas" x-show="showThumbnailCropper" style="display: none; max-width: 400px; margin-top: 1rem; border: 1px solid #dbdbdb;"></canvas> 186 <button 187 type="button" 188 x-show="showThumbnailCropper" 189 @click="uploadCroppedThumbnail()" 190 class="button is-primary mt-2" 191 style="display: none;" 192 :disabled="uploadingThumbnail"> 193 <span x-text="uploadingThumbnail ? 'Uploading...' : 'Upload Cropped Thumbnail'"></span> 194 </button> 195 196 <!-- Thumbnail Preview --> 197 <div x-show="formData.thumbnailCid" style="display: none; margin-top: 10px;"> 198 <img :src="thumbnailPreviewUrl" style="width: 150px; height: 150px; object-fit: cover; border: 1px solid #dbdbdb;"> 199 <br> 200 <!-- Thumbnail Alt Text --> 201 <div class="field mt-2"> 202 <label class="label is-small">Thumbnail Alt Text (optional)</label> 203 <div class="control"> 204 <input type="text" class="input is-small" x-model="formData.thumbnailAlt" placeholder="Describe the thumbnail for accessibility" maxlength="200"> 205 </div> 206 </div> 207 <button type="button" class="button is-small is-danger mt-2" @click="removeThumbnail()">Remove Thumbnail</button> 208 </div> 209 </div> 210 211 <!-- Status and Mode --> 212 <div class="field"> 213 <div class="field-body"> 214 <!-- Status --> 215 <div class="field"> 216 <label class="label" for="eventStatus">Status</label> 217 <div class="control"> 218 <div class="select"> 219 <select id="eventStatus" x-model="formData.status"> 220 <option value="planned">Planned</option> 221 <option value="scheduled">Scheduled</option> 222 <option value="cancelled">Cancelled</option> 223 <option value="postponed">Postponed</option> 224 <option value="rescheduled">Rescheduled</option> 225 </select> 226 </div> 227 </div> 228 </div> 229 230 <!-- Mode --> 231 <div class="field pb-5"> 232 <label class="label" for="eventMode">Mode</label> 233 <div class="control"> 234 <div class="select"> 235 <select id="eventMode" x-model="formData.mode"> 236 <option value="inperson">In Person</option> 237 <option value="virtual">Virtual</option> 238 <option value="hybrid">Hybrid</option> 239 </select> 240 </div> 241 </div> 242 </div> 243 </div> 244 </div> 245 </div> 246 247 <!-- Date & Time Box --> 248 <div class="box content pb-0"> 249 <div class="field"> 250 <label class="label">Date & Time</label> 251 252 <!-- Timezone --> 253 <div class="field"> 254 <label class="label is-small" for="eventTimezone">Timezone</label> 255 <div class="control"> 256 <div class="select is-fullwidth"> 257 <select id="eventTimezone" x-model="formData.tz"> 258 {% for timezone in timezones %} 259 <option value="{{ timezone }}">{{ timezone }}</option> 260 {% endfor %} 261 </select> 262 </div> 263 </div> 264 </div> 265 266 <!-- Start Date/Time --> 267 <div class="field"> 268 <label class="label is-small" for="eventStartDateTime">Start Date & Time (optional)</label> 269 <div class="field has-addons"> 270 <div class="control is-expanded"> 271 <input 272 id="eventStartDateTime" 273 type="datetime-local" 274 class="input" 275 x-model="startDateTimeLocal"> 276 </div> 277 <div class="control"> 278 <button 279 type="button" 280 class="button" 281 @click="clearStartDateTime()" 282 title="Clear start date & time"> 283 <span class="icon is-small"> 284 <i class="fas fa-times"></i> 285 </span> 286 </button> 287 </div> 288 </div> 289 </div> 290 291 <!-- End Date/Time --> 292 <div class="field pb-5"> 293 <label class="label is-small" for="eventEndDateTime">End Date & Time (optional)</label> 294 <div class="field has-addons"> 295 <div class="control is-expanded"> 296 <input 297 id="eventEndDateTime" 298 type="datetime-local" 299 class="input" 300 x-model="endDateTimeLocal"> 301 </div> 302 <div class="control"> 303 <button 304 type="button" 305 class="button" 306 @click="clearEndDateTime()" 307 title="Clear end date & time"> 308 <span class="icon is-small"> 309 <i class="fas fa-times"></i> 310 </span> 311 </button> 312 </div> 313 </div> 314 </div> 315 </div> 316 </div> 317 318 <!-- Locations Box --> 319 <div class="box content"> 320 <div class="field"> 321 <label class="label">Locations</label> 322 <p class="help mb-3">Add physical addresses for your event, or leave blank for online events.</p> 323 324 <!-- Location Suggestions --> 325 <div class="mb-4" x-show="addressLocations.length === 0 || showLocationSuggestions"> 326 <button 327 type="button" 328 @click="fetchLocationSuggestions()" 329 class="button is-link is-light is-small" 330 :disabled="loadingSuggestions"> 331 <span class="icon is-small"> 332 <i class="fas" :class="loadingSuggestions ? 'fa-spinner fa-pulse' : 'fa-magic'"></i> 333 </span> 334 <span x-text="loadingSuggestions ? 'Loading...' : 'Use saved location'"></span> 335 </button> 336 337 <template x-if="showLocationSuggestions && locationSuggestions.length > 0"> 338 <div class="mt-3"> 339 <!-- Search Input --> 340 <div class="field"> 341 <div class="control has-icons-left"> 342 <input 343 type="text" 344 class="input is-small" 345 placeholder="Filter locations..." 346 x-model="locationFilter" 347 @keydown.escape="locationFilter = ''"> 348 <span class="icon is-small is-left"> 349 <i class="fas fa-search"></i> 350 </span> 351 </div> 352 </div> 353 354 <!-- Results Table --> 355 <div class="table-container" style="max-height: 250px; overflow-y: auto;"> 356 <table class="table is-fullwidth is-hoverable is-narrow is-striped"> 357 <thead> 358 <tr> 359 <th style="width: 24px;"></th> 360 <th>Name</th> 361 <th>Location</th> 362 </tr> 363 </thead> 364 <tbody> 365 <template x-for="(suggestion, index) in filteredLocationSuggestions" :key="index"> 366 <tr 367 @click="applyLocationSuggestion(suggestion)" 368 style="cursor: pointer;"> 369 <td style="padding: 0.25em;"> 370 <img x-show="suggestion.source && suggestion.source.includes('beaconbits')" 371 src="/static/logo-beaconbits.svg" 372 alt="Beaconbits" 373 style="width: 18px; height: 18px; vertical-align: middle;"> 374 <img x-show="suggestion.source && suggestion.source.includes('dropanchor')" 375 src="/static/logo-dropanchor.png" 376 alt="Drop Anchor" 377 style="width: 18px; height: 18px; vertical-align: middle;"> 378 <img x-show="suggestion.source && suggestion.source.includes('calendar')" 379 src="/static/logo-160x160.png" 380 alt="Smokesignal" 381 style="width: 18px; height: 18px; vertical-align: middle;"> 382 </td> 383 <td> 384 <span x-text="suggestion.name || '—'"></span> 385 </td> 386 <td> 387 <span class="is-size-7" x-text="[suggestion.street, suggestion.locality, suggestion.region, suggestion.postal_code, suggestion.country].filter(Boolean).join(', ') || (suggestion.latitude && suggestion.longitude ? suggestion.latitude + ', ' + suggestion.longitude : '—')"></span> 388 </td> 389 </tr> 390 </template> 391 </tbody> 392 </table> 393 </div> 394 395 <!-- No Results --> 396 <p x-show="filteredLocationSuggestions.length === 0 && locationFilter.trim()" 397 class="is-size-7 has-text-grey mt-2"> 398 No locations match "<span x-text="locationFilter"></span>" 399 </p> 400 401 <!-- Actions --> 402 <div class="mt-2"> 403 <button type="button" @click="showLocationSuggestions = false; locationFilter = ''" 404 class="button is-small is-ghost"> 405 Hide 406 </button> 407 </div> 408 </div> 409 </template> 410 411 <template x-if="showLocationSuggestions && !loadingSuggestions && locationSuggestions.length === 0"> 412 <p class="is-size-7 has-text-grey mt-2">No saved locations found.</p> 413 </template> 414 </div> 415 416 <!-- Location Feedback --> 417 <div x-show="locationFeedback" x-transition class="notification is-success is-light mb-3" style="padding: 0.75rem 1rem;"> 418 <span class="icon is-small"> 419 <i class="fas fa-check"></i> 420 </span> 421 <span x-text="locationFeedback"></span> 422 </div> 423 424 <!-- Address Location List --> 425 <template x-for="(location, index) in addressLocations" :key="'addr-' + index"> 426 <div class="box" style="background-color: #f5f5f5;"> 427 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;"> 428 <strong>Location <span x-text="addressLocationIndex(index) + 1"></span></strong> 429 <button 430 type="button" 431 @click="removeAddressLocation(index)" 432 class="delete is-small" 433 title="Remove location"></button> 434 </div> 435 436 <!-- Country --> 437 <div class="field"> 438 <label class="label is-small">Country (required)</label> 439 <div class="control"> 440 <div class="select is-fullwidth"> 441 <select x-model="location.country" required> 442 <option value="">Select a country...</option> 443 <optgroup label="Popular Countries"> 444 {% for (code, name) in popular_countries %} 445 <option value="{{ code }}">{{ name }}</option> 446 {% endfor %} 447 </optgroup> 448 <optgroup label="All Countries"> 449 {% for (code, name) in other_countries %} 450 <option value="{{ code }}">{{ name }}</option> 451 {% endfor %} 452 </optgroup> 453 </select> 454 </div> 455 </div> 456 </div> 457 458 <!-- Location Name --> 459 <div class="field"> 460 <label class="label is-small">Location Name (optional)</label> 461 <div class="control"> 462 <input 463 type="text" 464 class="input" 465 x-model="location.name" 466 placeholder="e.g., The Gem City" 467 maxlength="200"> 468 </div> 469 </div> 470 471 <!-- Street --> 472 <div class="field"> 473 <label class="label is-small">Street Address (optional)</label> 474 <div class="control"> 475 <input 476 type="text" 477 class="input" 478 x-model="location.street" 479 placeholder="e.g., 555 Somewhere St" 480 maxlength="200"> 481 </div> 482 </div> 483 484 <!-- City --> 485 <div class="field"> 486 <label class="label is-small">City (optional)</label> 487 <div class="control"> 488 <input 489 type="text" 490 class="input" 491 x-model="location.locality" 492 placeholder="e.g., Dayton" 493 maxlength="200"> 494 </div> 495 </div> 496 497 <!-- State/Region --> 498 <div class="field"> 499 <label class="label is-small">State/Region (optional)</label> 500 <div class="control"> 501 <input 502 type="text" 503 class="input" 504 x-model="location.region" 505 placeholder="e.g., Ohio" 506 maxlength="200"> 507 </div> 508 </div> 509 510 <!-- Postal Code --> 511 <div class="field"> 512 <label class="label is-small">Postal Code (optional)</label> 513 <div class="control"> 514 <input 515 type="text" 516 class="input" 517 x-model="location.postalCode" 518 placeholder="e.g., 11111" 519 maxlength="200"> 520 </div> 521 </div> 522 </div> 523 </template> 524 525 <!-- Add Location Button --> 526 <button 527 type="button" 528 @click="addLocation()" 529 class="button is-link is-outlined is-fullwidth"> 530 <span class="icon"> 531 <i class="fas fa-plus"></i> 532 </span> 533 <span>Add Location</span> 534 </button> 535 536 <!-- Geo Locations Section --> 537 <div class="mt-4"> 538 <p class="help mb-3">Or add GPS coordinates:</p> 539 540 <template x-for="(geo, index) in geoLocations" :key="'geo-' + index"> 541 <div class="box" style="background-color: #f5f5f5;"> 542 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;"> 543 <strong>Coordinates <span x-text="geoLocationIndex(index) + 1"></span></strong> 544 <button 545 type="button" 546 @click="removeGeoLocation(index)" 547 class="delete is-small" 548 title="Remove coordinates"></button> 549 </div> 550 551 <div class="columns is-mobile"> 552 <div class="column"> 553 <div class="field"> 554 <label class="label is-small">Latitude (required)</label> 555 <div class="control"> 556 <input 557 type="text" 558 class="input" 559 x-model="geo.latitude" 560 placeholder="e.g., 39.7066186" 561 pattern="-?[0-9]*\.?[0-9]+" 562 required> 563 </div> 564 <p class="help">-90 to 90</p> 565 </div> 566 </div> 567 <div class="column"> 568 <div class="field"> 569 <label class="label is-small">Longitude (required)</label> 570 <div class="control"> 571 <input 572 type="text" 573 class="input" 574 x-model="geo.longitude" 575 placeholder="e.g., -84.1702195" 576 pattern="-?[0-9]*\.?[0-9]+" 577 required> 578 </div> 579 <p class="help">-180 to 180</p> 580 </div> 581 </div> 582 </div> 583 584 <div class="field"> 585 <label class="label is-small">Location Name (optional)</label> 586 <div class="control"> 587 <input 588 type="text" 589 class="input" 590 x-model="geo.name" 591 placeholder="e.g., Central Park" 592 maxlength="200"> 593 </div> 594 </div> 595 </div> 596 </template> 597 598 <button 599 type="button" 600 @click="addGeoLocation()" 601 class="button is-link is-outlined is-small"> 602 <span class="icon"> 603 <i class="fas fa-map-pin"></i> 604 </span> 605 <span>Add Coordinates</span> 606 </button> 607 </div> 608 </div> 609 </div> 610 611 <!-- Links Box --> 612 <div class="box content"> 613 <div class="field"> 614 <label class="label">Links</label> 615 <p class="help mb-3">Add relevant links for your event (registration, livestream, etc.).</p> 616 617 <!-- Link List --> 618 <template x-for="(link, index) in formData.links" :key="index"> 619 <div class="box" style="background-color: #f5f5f5;"> 620 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;"> 621 <strong>Link <span x-text="index + 1"></span></strong> 622 <button 623 type="button" 624 @click="removeLink(index)" 625 class="delete is-small" 626 title="Remove link"></button> 627 </div> 628 629 <!-- URL --> 630 <div class="field"> 631 <label class="label is-small">URL (required)</label> 632 <div class="control"> 633 <input 634 type="url" 635 class="input" 636 x-model="link.url" 637 placeholder="https://example.com" 638 required> 639 </div> 640 </div> 641 642 <!-- Label --> 643 <div class="field"> 644 <label class="label is-small">Label (optional)</label> 645 <div class="control"> 646 <input 647 type="text" 648 class="input" 649 x-model="link.label" 650 placeholder="e.g., Register Here" 651 maxlength="100"> 652 </div> 653 </div> 654 </div> 655 </template> 656 657 <!-- Add Link Button --> 658 <button 659 type="button" 660 @click="addLink()" 661 class="button is-link is-outlined is-fullwidth"> 662 <span class="icon"> 663 <i class="fas fa-plus"></i> 664 </span> 665 <span>Add Link</span> 666 </button> 667 </div> 668 </div> 669 670 <!-- Options Box --> 671 <div class="box content"> 672 <div class="field"> 673 <div class="control"> 674 <label class="checkbox"> 675 <input type="checkbox" x-model="formData.requireConfirmedEmail"> 676 Require confirmed email addresses for RSVPs 677 </label> 678 </div> 679 <p class="help">Check this box to restrict RSVPs to users who have confirmed their email addresses.</p> 680 </div> 681 682 <!-- Only show notification option when editing --> 683 <div class="field" x-show="isEditMode"> 684 <div class="control"> 685 <label class="checkbox"> 686 <input type="checkbox" x-model="formData.sendNotifications"> 687 Send event update notification to attendees 688 </label> 689 </div> 690 <p class="help">Check this box to notify all RSVPs that this event has been updated. Only users who have event change notifications enabled will receive emails.</p> 691 </div> 692 </div> 693 694 <!-- Submit Button --> 695 <div class="field"> 696 <div class="control"> 697 <button 698 type="submit" 699 class="button is-link" 700 :disabled="submitting" 701 :class="{ 'is-loading': submitting }"> 702 <span x-text="submitting ? (isEditMode ? 'Updating...' : 'Creating...') : (isEditMode ? 'Update Event' : 'Create Event')"></span> 703 </button> 704 {% if cancel_url %} 705 <a href="{{ cancel_url }}" class="button">Cancel</a> 706 {% endif %} 707 </div> 708 </div> 709 710 <!-- Debug (development only) --> 711 {% if is_development %} 712 <div class="box"> 713 <h4>Form Data (Debug)</h4> 714 <pre x-text="JSON.stringify(formData, null, 2)" style="font-size: 0.8rem; max-height: 400px; overflow: auto;"></pre> 715 </div> 716 {% endif %} 717 </form> 718</div> 719 720<script> 721function eventForm() { 722 return { 723 submitUrl: {% if submit_url %}{{ submit_url | tojson }}{% else %}'/event'{% endif %}, 724 isEditMode: {% if create_event %}false{% else %}true{% endif %}, 725 formData: {{ event_data | tojson }}, 726 startDateTimeLocal: '', 727 endDateTimeLocal: '', 728 submitting: false, 729 submitted: false, 730 uploading: false, 731 uploadingThumbnail: false, 732 errorMessage: null, 733 eventUrl: null, 734 headerCropper: null, 735 thumbnailCropper: null, 736 showCropper: false, 737 showThumbnailCropper: false, 738 showDescriptionPreview: false, 739 descriptionPreviewHtml: '', 740 loadingPreview: false, 741 locationSuggestions: [], 742 loadingSuggestions: false, 743 showLocationSuggestions: false, 744 locationFeedback: null, 745 locationFilter: '', 746 747 init() { 748 // Convert ISO datetime to datetime-local format if we have existing data 749 if (this.formData.startsAt) { 750 const startDate = new Date(this.formData.startsAt); 751 this.startDateTimeLocal = this.formatDateTimeLocal(startDate); 752 } else { 753 // Set default start time to next 6 PM after 3 hours from now 754 const now = new Date(); 755 now.setHours(now.getHours() + 3); 756 757 let targetDate = new Date(now); 758 if (now.getHours() >= 18) { 759 targetDate.setDate(targetDate.getDate() + 1); 760 } 761 targetDate.setHours(18, 0, 0, 0); 762 763 this.startDateTimeLocal = this.formatDateTimeLocal(targetDate); 764 } 765 766 if (this.formData.endsAt) { 767 const endDate = new Date(this.formData.endsAt); 768 this.endDateTimeLocal = this.formatDateTimeLocal(endDate); 769 } 770 771 // Watch datetime-local inputs and update formData 772 this.$watch('startDateTimeLocal', (value) => { 773 if (value) { 774 const date = new Date(value); 775 this.formData.startsAt = date.toISOString(); 776 } else { 777 this.formData.startsAt = null; 778 } 779 }); 780 781 this.$watch('endDateTimeLocal', (value) => { 782 if (value) { 783 const date = new Date(value); 784 this.formData.endsAt = date.toISOString(); 785 } else { 786 this.formData.endsAt = null; 787 } 788 }); 789 }, 790 791 // Computed properties to filter locations by type 792 get addressLocations() { 793 return this.formData.locations.filter(loc => loc.type === 'address'); 794 }, 795 796 get geoLocations() { 797 return this.formData.locations.filter(loc => loc.type === 'geo'); 798 }, 799 800 // Get the actual index within the filtered array 801 addressLocationIndex(filteredIndex) { 802 return filteredIndex; 803 }, 804 805 geoLocationIndex(filteredIndex) { 806 return filteredIndex; 807 }, 808 809 // Find the original array index for an address location 810 findAddressLocationIndex(filteredIndex) { 811 let count = 0; 812 for (let i = 0; i < this.formData.locations.length; i++) { 813 if (this.formData.locations[i].type === 'address') { 814 if (count === filteredIndex) return i; 815 count++; 816 } 817 } 818 return -1; 819 }, 820 821 // Find the original array index for a geo location 822 findGeoLocationIndex(filteredIndex) { 823 let count = 0; 824 for (let i = 0; i < this.formData.locations.length; i++) { 825 if (this.formData.locations[i].type === 'geo') { 826 if (count === filteredIndex) return i; 827 count++; 828 } 829 } 830 return -1; 831 }, 832 833 formatDateTimeLocal(date) { 834 const year = date.getFullYear(); 835 const month = String(date.getMonth() + 1).padStart(2, '0'); 836 const day = String(date.getDate()).padStart(2, '0'); 837 const hours = String(date.getHours()).padStart(2, '0'); 838 const minutes = String(date.getMinutes()).padStart(2, '0'); 839 return `${year}-${month}-${day}T${hours}:${minutes}`; 840 }, 841 842 get headerPreviewUrl() { 843 return this.formData.headerCid ? `/content/${this.formData.headerCid}.png` : null; 844 }, 845 846 get thumbnailPreviewUrl() { 847 return this.formData.thumbnailCid ? `/content/${this.formData.thumbnailCid}.png` : null; 848 }, 849 850 get filteredLocationSuggestions() { 851 if (!this.locationFilter.trim()) { 852 return this.locationSuggestions; 853 } 854 const query = this.locationFilter.toLowerCase().trim(); 855 return this.locationSuggestions.filter(suggestion => { 856 const searchFields = [ 857 suggestion.name, 858 suggestion.street, 859 suggestion.locality, 860 suggestion.region, 861 suggestion.postal_code, 862 suggestion.country, 863 ].filter(Boolean).map(f => f.toLowerCase()); 864 865 const queryTerms = query.split(/\s+/); 866 return queryTerms.every(term => 867 searchFields.some(field => field.includes(term)) 868 ); 869 }); 870 }, 871 872 addLocation() { 873 this.formData.locations.push({ 874 type: 'address', 875 country: '', 876 postalCode: null, 877 region: null, 878 locality: null, 879 street: null, 880 name: null, 881 }); 882 }, 883 884 removeAddressLocation(filteredIndex) { 885 const actualIndex = this.findAddressLocationIndex(filteredIndex); 886 if (actualIndex !== -1) { 887 this.formData.locations.splice(actualIndex, 1); 888 } 889 }, 890 891 addGeoLocation() { 892 this.formData.locations.push({ 893 type: 'geo', 894 latitude: '', 895 longitude: '', 896 name: null, 897 }); 898 }, 899 900 removeGeoLocation(filteredIndex) { 901 const actualIndex = this.findGeoLocationIndex(filteredIndex); 902 if (actualIndex !== -1) { 903 this.formData.locations.splice(actualIndex, 1); 904 } 905 }, 906 907 async fetchLocationSuggestions() { 908 if (this.loadingSuggestions || this.locationSuggestions.length > 0) { 909 this.showLocationSuggestions = true; 910 return; 911 } 912 this.loadingSuggestions = true; 913 try { 914 const response = await fetch('/event/location-suggestions'); 915 const data = await response.json(); 916 if (response.ok && data.suggestions) { 917 this.locationSuggestions = data.suggestions; 918 } 919 } catch (error) { 920 console.error('Failed to fetch location suggestions:', error); 921 } finally { 922 this.loadingSuggestions = false; 923 this.showLocationSuggestions = true; 924 } 925 }, 926 927 applyLocationSuggestion(suggestion) { 928 const hasAddress = !!suggestion.country; 929 const hasGeo = !!(suggestion.latitude && suggestion.longitude); 930 931 // Add address location if country is available 932 if (hasAddress) { 933 this.formData.locations.push({ 934 type: 'address', 935 country: suggestion.country || '', 936 postalCode: suggestion.postal_code || null, 937 region: suggestion.region || null, 938 locality: suggestion.locality || null, 939 street: suggestion.street || null, 940 name: suggestion.name || null, 941 }); 942 } 943 // Also add geo coordinates if available 944 if (hasGeo) { 945 this.formData.locations.push({ 946 type: 'geo', 947 latitude: suggestion.latitude, 948 longitude: suggestion.longitude, 949 name: suggestion.name || null, 950 }); 951 } 952 953 // Show feedback about what was added 954 if (hasAddress && hasGeo) { 955 this.locationFeedback = 'Added address and coordinates'; 956 } else if (hasAddress) { 957 this.locationFeedback = 'Added address'; 958 } else if (hasGeo) { 959 this.locationFeedback = 'Added coordinates'; 960 } 961 962 if (this.locationFeedback) { 963 setTimeout(() => { 964 this.locationFeedback = null; 965 }, 3000); 966 } 967 968 this.showLocationSuggestions = false; 969 this.locationFilter = ''; 970 }, 971 972 addLink() { 973 this.formData.links.push({ 974 url: '', 975 label: null, 976 }); 977 }, 978 979 removeLink(index) { 980 this.formData.links.splice(index, 1); 981 }, 982 983 async toggleDescriptionPreview() { 984 if (this.showDescriptionPreview) { 985 this.showDescriptionPreview = false; 986 return; 987 } 988 989 if (!this.formData.description || this.formData.description.trim().length < 10) { 990 alert('Description must be at least 10 characters to preview'); 991 return; 992 } 993 994 this.showDescriptionPreview = true; 995 this.loadingPreview = true; 996 this.descriptionPreviewHtml = ''; 997 998 try { 999 const response = await fetch('/event/preview-description', { 1000 method: 'POST', 1001 headers: { 1002 'Content-Type': 'application/json', 1003 }, 1004 body: JSON.stringify({ 1005 description: this.formData.description 1006 }) 1007 }); 1008 1009 const data = await response.json(); 1010 1011 if (response.ok) { 1012 this.descriptionPreviewHtml = data.html; 1013 } else { 1014 this.errorMessage = data.error || 'Failed to load preview'; 1015 this.showDescriptionPreview = false; 1016 } 1017 } catch (error) { 1018 console.error('Preview error:', error); 1019 this.errorMessage = 'Failed to load preview. Please try again.'; 1020 this.showDescriptionPreview = false; 1021 } finally { 1022 this.loadingPreview = false; 1023 } 1024 }, 1025 1026 handleHeaderImageSelect(event) { 1027 const file = event.target.files[0]; 1028 if (!file) return; 1029 1030 if (file.size > 3000000) { 1031 alert('Image must be smaller than 3MB'); 1032 event.target.value = ''; 1033 return; 1034 } 1035 1036 const reader = new FileReader(); 1037 reader.onload = (e) => { 1038 const img = new Image(); 1039 img.onload = () => { 1040 const canvas = document.getElementById('headerCanvas'); 1041 canvas.width = img.width; 1042 canvas.height = img.height; 1043 this.showCropper = true; 1044 1045 const ctx = canvas.getContext('2d'); 1046 ctx.drawImage(img, 0, 0); 1047 1048 if (this.headerCropper) { 1049 this.headerCropper.destroy(); 1050 } 1051 1052 this.headerCropper = new Cropper(canvas, { 1053 aspectRatio: 3 / 1, 1054 viewMode: 1, 1055 autoCropArea: 1, 1056 responsive: true, 1057 background: false, 1058 }); 1059 }; 1060 img.src = e.target.result; 1061 }; 1062 reader.readAsDataURL(file); 1063 }, 1064 1065 async uploadCroppedHeader() { 1066 if (!this.headerCropper) return; 1067 1068 this.uploading = true; 1069 1070 try { 1071 const blob = await new Promise((resolve) => { 1072 this.headerCropper.getCroppedCanvas({ 1073 width: 1500, 1074 height: 500, 1075 }).toBlob(resolve, 'image/png'); 1076 }); 1077 1078 const formData = new FormData(); 1079 formData.append('header', blob, 'header.png'); 1080 1081 const response = await fetch('/event/upload-header', { 1082 method: 'POST', 1083 body: formData 1084 }); 1085 1086 if (response.ok) { 1087 const data = await response.json(); 1088 this.formData.headerCid = data.cid; 1089 this.formData.headerAlt = ''; 1090 this.formData.headerSize = data.size; 1091 1092 this.showCropper = false; 1093 if (this.headerCropper) { 1094 this.headerCropper.destroy(); 1095 this.headerCropper = null; 1096 } 1097 1098 document.querySelector('input[type=file]').value = ''; 1099 } else { 1100 alert('Failed to upload header image. Please try again.'); 1101 } 1102 } catch (error) { 1103 console.error('Upload error:', error); 1104 alert('Failed to upload header image'); 1105 } finally { 1106 this.uploading = false; 1107 } 1108 }, 1109 1110 removeHeader() { 1111 this.formData.headerCid = null; 1112 this.formData.headerAlt = null; 1113 this.formData.headerSize = null; 1114 1115 if (this.headerCropper) { 1116 this.headerCropper.destroy(); 1117 this.headerCropper = null; 1118 } 1119 this.showCropper = false; 1120 1121 const headerInput = document.querySelector('.file-input:not(#thumbnailInput)'); 1122 if (headerInput) headerInput.value = ''; 1123 }, 1124 1125 handleThumbnailImageSelect(event) { 1126 const file = event.target.files[0]; 1127 if (!file) return; 1128 1129 if (file.size > 3000000) { 1130 alert('Image must be smaller than 3MB'); 1131 event.target.value = ''; 1132 return; 1133 } 1134 1135 const reader = new FileReader(); 1136 reader.onload = (e) => { 1137 const img = new Image(); 1138 img.onload = () => { 1139 const canvas = document.getElementById('thumbnailCanvas'); 1140 canvas.width = img.width; 1141 canvas.height = img.height; 1142 this.showThumbnailCropper = true; 1143 1144 const ctx = canvas.getContext('2d'); 1145 ctx.drawImage(img, 0, 0); 1146 1147 if (this.thumbnailCropper) { 1148 this.thumbnailCropper.destroy(); 1149 } 1150 1151 this.thumbnailCropper = new Cropper(canvas, { 1152 aspectRatio: 1, 1153 viewMode: 1, 1154 autoCropArea: 1, 1155 responsive: true, 1156 background: false, 1157 }); 1158 }; 1159 img.src = e.target.result; 1160 }; 1161 reader.readAsDataURL(file); 1162 }, 1163 1164 async uploadCroppedThumbnail() { 1165 if (!this.thumbnailCropper) return; 1166 1167 this.uploadingThumbnail = true; 1168 1169 try { 1170 const blob = await new Promise((resolve) => { 1171 this.thumbnailCropper.getCroppedCanvas({ 1172 width: 1024, 1173 height: 1024, 1174 }).toBlob(resolve, 'image/png'); 1175 }); 1176 1177 const formData = new FormData(); 1178 formData.append('thumbnail', blob, 'thumbnail.png'); 1179 1180 const response = await fetch('/event/upload-thumbnail', { 1181 method: 'POST', 1182 body: formData 1183 }); 1184 1185 if (response.ok) { 1186 const data = await response.json(); 1187 this.formData.thumbnailCid = data.cid; 1188 this.formData.thumbnailAlt = ''; 1189 1190 this.showThumbnailCropper = false; 1191 if (this.thumbnailCropper) { 1192 this.thumbnailCropper.destroy(); 1193 this.thumbnailCropper = null; 1194 } 1195 1196 document.getElementById('thumbnailInput').value = ''; 1197 } else { 1198 alert('Failed to upload thumbnail image. Please try again.'); 1199 } 1200 } catch (error) { 1201 console.error('Upload error:', error); 1202 alert('Failed to upload thumbnail image'); 1203 } finally { 1204 this.uploadingThumbnail = false; 1205 } 1206 }, 1207 1208 removeThumbnail() { 1209 this.formData.thumbnailCid = null; 1210 this.formData.thumbnailAlt = null; 1211 1212 if (this.thumbnailCropper) { 1213 this.thumbnailCropper.destroy(); 1214 this.thumbnailCropper = null; 1215 } 1216 this.showThumbnailCropper = false; 1217 1218 document.getElementById('thumbnailInput').value = ''; 1219 }, 1220 1221 clearStartDateTime() { 1222 const input = document.getElementById('eventStartDateTime'); 1223 if (input) { 1224 input.value = ''; 1225 } 1226 this.startDateTimeLocal = ''; 1227 this.formData.startsAt = null; 1228 }, 1229 1230 clearEndDateTime() { 1231 const input = document.getElementById('eventEndDateTime'); 1232 if (input) { 1233 input.value = ''; 1234 } 1235 this.endDateTimeLocal = ''; 1236 this.formData.endsAt = null; 1237 }, 1238 1239 async submitForm() { 1240 this.errorMessage = null; 1241 1242 // Validate name 1243 if (!this.formData.name || this.formData.name.trim().length < 10) { 1244 this.errorMessage = 'Event name must be at least 10 characters.'; 1245 return; 1246 } 1247 if (this.formData.name.trim().length > 500) { 1248 this.errorMessage = 'Event name must be no more than 500 characters.'; 1249 return; 1250 } 1251 1252 // Validate description 1253 if (!this.formData.description || this.formData.description.trim().length < 10) { 1254 this.errorMessage = 'Description must be at least 10 characters.'; 1255 return; 1256 } 1257 if (this.formData.description.trim().length > 3000) { 1258 this.errorMessage = 'Description must be no more than 3000 characters.'; 1259 return; 1260 } 1261 1262 // Validate address locations - all must have a country 1263 const invalidAddresses = this.addressLocations.filter(loc => !loc.country || loc.country.trim() === ''); 1264 if (invalidAddresses.length > 0) { 1265 this.errorMessage = 'All locations must have a country selected. Please select a country or remove the location.'; 1266 return; 1267 } 1268 1269 // Validate geo locations 1270 for (let i = 0; i < this.geoLocations.length; i++) { 1271 const geo = this.geoLocations[i]; 1272 if (!geo.latitude || !geo.longitude) { 1273 this.errorMessage = `Coordinates ${i + 1} must have both latitude and longitude.`; 1274 return; 1275 } 1276 const lat = parseFloat(geo.latitude); 1277 const lon = parseFloat(geo.longitude); 1278 if (isNaN(lat) || lat < -90 || lat > 90) { 1279 this.errorMessage = `Coordinates ${i + 1} has invalid latitude. Must be between -90 and 90.`; 1280 return; 1281 } 1282 if (isNaN(lon) || lon < -180 || lon > 180) { 1283 this.errorMessage = `Coordinates ${i + 1} has invalid longitude. Must be between -180 and 180.`; 1284 return; 1285 } 1286 } 1287 1288 // Validate links 1289 const invalidLinks = []; 1290 for (let i = 0; i < this.formData.links.length; i++) { 1291 const link = this.formData.links[i]; 1292 1293 if (!link.url || link.url.trim() === '') { 1294 invalidLinks.push(`Link ${i + 1} must have a URL or be removed.`); 1295 continue; 1296 } 1297 1298 if (!link.url.startsWith('https://')) { 1299 invalidLinks.push(`Link ${i + 1} must be an HTTPS URL (starting with https://).`); 1300 continue; 1301 } 1302 1303 try { 1304 new URL(link.url); 1305 } catch (e) { 1306 invalidLinks.push(`Link ${i + 1} has an invalid URL format.`); 1307 } 1308 } 1309 1310 if (invalidLinks.length > 0) { 1311 this.errorMessage = invalidLinks.join(' '); 1312 return; 1313 } 1314 1315 this.submitting = true; 1316 1317 // Clean up locations - remove invalid entries 1318 this.formData.locations = this.formData.locations.filter(loc => { 1319 if (loc.type === 'address') { 1320 return loc.country && loc.country.trim() !== ''; 1321 } else if (loc.type === 'geo') { 1322 return loc.latitude && loc.longitude; 1323 } 1324 return false; 1325 }); 1326 1327 // Clean up links 1328 this.formData.links = this.formData.links.filter(link => link.url); 1329 1330 // Ensure empty string end dates are converted to null 1331 if (this.formData.endsAt === '') { 1332 this.formData.endsAt = null; 1333 } 1334 1335 try { 1336 const response = await fetch(this.submitUrl, { 1337 method: 'POST', 1338 headers: { 1339 'Content-Type': 'application/json', 1340 }, 1341 body: JSON.stringify(this.formData) 1342 }); 1343 1344 const data = await response.json(); 1345 1346 if (response.ok) { 1347 this.submitted = true; 1348 this.eventUrl = data.url; 1349 } else { 1350 this.errorMessage = data.error || 'Failed to ' + (this.isEditMode ? 'update' : 'create') + ' event. Please try again.'; 1351 } 1352 } catch (error) { 1353 console.error('Submit error:', error); 1354 this.errorMessage = 'Network error. Please check your connection and try again.'; 1355 } finally { 1356 this.submitting = false; 1357 } 1358 }, 1359 1360 resetForm() { 1361 this.formData = { 1362 name: '', 1363 description: '', 1364 status: 'scheduled', 1365 mode: 'inperson', 1366 tz: '{{ default_tz }}', 1367 startsAt: null, 1368 endsAt: null, 1369 locations: [], 1370 links: [], 1371 headerCid: null, 1372 headerAlt: null, 1373 headerSize: null, 1374 thumbnailCid: null, 1375 thumbnailAlt: null, 1376 requireConfirmedEmail: false, 1377 sendNotifications: false, 1378 }; 1379 this.startDateTimeLocal = ''; 1380 this.endDateTimeLocal = ''; 1381 this.submitted = false; 1382 this.eventUrl = null; 1383 this.errorMessage = null; 1384 this.init(); 1385 }, 1386 }; 1387} 1388</script> 1389 1390<style> 1391[x-cloak] { display: none !important; } 1392</style>