forked from
smokesignal.events/smokesignal
The smokesignal.events web application
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>