your personal website on atproto - mirror blento.app

add event creation page

Florian ce94823f 1cc74545

+295
+295
src/routes/[[actor=actor]]/events/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import { user } from '$lib/atproto/auth.svelte'; 3 + import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 4 + import { uploadBlob } from '$lib/atproto/methods'; 5 + import { compressImage } from '$lib/atproto/image-helper'; 6 + import { Button } from '@foxui/core'; 7 + import { goto } from '$app/navigation'; 8 + 9 + let name = $state(''); 10 + let description = $state(''); 11 + let startsAt = $state(''); 12 + let endsAt = $state(''); 13 + let thumbnailFile: File | null = $state(null); 14 + let thumbnailPreview: string | null = $state(null); 15 + let submitting = $state(false); 16 + let error: string | null = $state(null); 17 + 18 + let fileInput: HTMLInputElement | undefined = $state(); 19 + 20 + function onFileChange(e: Event) { 21 + const input = e.target as HTMLInputElement; 22 + const file = input.files?.[0]; 23 + if (!file) return; 24 + thumbnailFile = file; 25 + if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 26 + thumbnailPreview = URL.createObjectURL(file); 27 + } 28 + 29 + function removeThumbnail() { 30 + thumbnailFile = null; 31 + if (thumbnailPreview) { 32 + URL.revokeObjectURL(thumbnailPreview); 33 + thumbnailPreview = null; 34 + } 35 + if (fileInput) fileInput.value = ''; 36 + } 37 + 38 + async function handleSubmit() { 39 + error = null; 40 + 41 + if (!name.trim()) { 42 + error = 'Name is required.'; 43 + return; 44 + } 45 + if (!startsAt) { 46 + error = 'Start date is required.'; 47 + return; 48 + } 49 + if (!user.client || !user.did) { 50 + error = 'You must be logged in.'; 51 + return; 52 + } 53 + 54 + submitting = true; 55 + 56 + try { 57 + let media: Array<Record<string, unknown>> | undefined; 58 + 59 + if (thumbnailFile) { 60 + const compressed = await compressImage(thumbnailFile); 61 + const blobRef = await uploadBlob({ blob: compressed.blob }); 62 + if (blobRef) { 63 + media = [ 64 + { 65 + role: 'thumbnail', 66 + content: blobRef, 67 + aspect_ratio: { 68 + width: compressed.aspectRatio.width, 69 + height: compressed.aspectRatio.height 70 + } 71 + } 72 + ]; 73 + } 74 + } 75 + 76 + const record: Record<string, unknown> = { 77 + $type: 'community.lexicon.calendar.event', 78 + name: name.trim(), 79 + mode: 'community.lexicon.calendar.event#inperson', 80 + status: 'community.lexicon.calendar.event#scheduled', 81 + startsAt: new Date(startsAt).toISOString(), 82 + createdAt: new Date().toISOString() 83 + }; 84 + 85 + if (description.trim()) { 86 + record.description = description.trim(); 87 + } 88 + if (endsAt) { 89 + record.endsAt = new Date(endsAt).toISOString(); 90 + } 91 + if (media) { 92 + record.media = media; 93 + } 94 + 95 + const response = await user.client.post('com.atproto.repo.createRecord', { 96 + input: { 97 + collection: 'community.lexicon.calendar.event', 98 + repo: user.did, 99 + record 100 + } 101 + }); 102 + 103 + if (response.ok) { 104 + const parts = response.data.uri.split('/'); 105 + const rkey = parts[parts.length - 1]; 106 + const handle = 107 + user.profile?.handle && user.profile.handle !== 'handle.invalid' 108 + ? user.profile.handle 109 + : user.did; 110 + goto(`/${handle}/e/${rkey}`); 111 + } else { 112 + error = 'Failed to create event. Please try again.'; 113 + } 114 + } catch (e) { 115 + console.error('Failed to create event:', e); 116 + error = 'Failed to create event. Please try again.'; 117 + } finally { 118 + submitting = false; 119 + } 120 + } 121 + </script> 122 + 123 + <svelte:head> 124 + <title>Create Event</title> 125 + </svelte:head> 126 + 127 + <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12"> 128 + <div class="mx-auto max-w-2xl"> 129 + <h1 class="text-base-900 dark:text-base-50 mb-8 text-3xl font-bold">Create Event</h1> 130 + 131 + {#if user.isInitializing} 132 + <div class="flex items-center gap-3"> 133 + <div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div> 134 + <div class="bg-base-300 dark:bg-base-700 h-4 w-48 animate-pulse rounded"></div> 135 + </div> 136 + {:else if !user.isLoggedIn} 137 + <div 138 + class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 rounded-2xl border p-8 text-center" 139 + > 140 + <p class="text-base-600 dark:text-base-400 mb-4">Log in to create an event.</p> 141 + <Button onclick={() => loginModalState.show()}>Log in</Button> 142 + </div> 143 + {:else} 144 + <form 145 + onsubmit={(e) => { 146 + e.preventDefault(); 147 + handleSubmit(); 148 + }} 149 + class="space-y-6" 150 + > 151 + <!-- Thumbnail --> 152 + <div> 153 + <label 154 + class="text-base-700 dark:text-base-300 mb-1.5 block text-sm font-medium" 155 + for="thumbnail" 156 + > 157 + Thumbnail 158 + </label> 159 + <input 160 + bind:this={fileInput} 161 + type="file" 162 + id="thumbnail" 163 + accept="image/*" 164 + onchange={onFileChange} 165 + class="hidden" 166 + /> 167 + {#if thumbnailPreview} 168 + <div class="relative inline-block"> 169 + <img 170 + src={thumbnailPreview} 171 + alt="Thumbnail preview" 172 + class="border-base-200 dark:border-base-700 h-40 w-40 rounded-xl border object-cover" 173 + /> 174 + <button 175 + type="button" 176 + onclick={removeThumbnail} 177 + aria-label="Remove thumbnail" 178 + class="bg-base-900/70 absolute -top-2 -right-2 flex size-6 items-center justify-center rounded-full text-white hover:bg-red-600" 179 + > 180 + <svg 181 + xmlns="http://www.w3.org/2000/svg" 182 + viewBox="0 0 20 20" 183 + fill="currentColor" 184 + class="size-3.5" 185 + > 186 + <path 187 + d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 188 + /> 189 + </svg> 190 + </button> 191 + </div> 192 + {:else} 193 + <button 194 + type="button" 195 + onclick={() => fileInput?.click()} 196 + class="border-base-300 dark:border-base-700 hover:border-base-400 dark:hover:border-base-600 text-base-500 dark:text-base-400 flex h-40 w-40 flex-col items-center justify-center rounded-xl border-2 border-dashed transition-colors" 197 + > 198 + <svg 199 + xmlns="http://www.w3.org/2000/svg" 200 + fill="none" 201 + viewBox="0 0 24 24" 202 + stroke-width="1.5" 203 + stroke="currentColor" 204 + class="mb-1 size-6" 205 + > 206 + <path 207 + stroke-linecap="round" 208 + stroke-linejoin="round" 209 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" 210 + /> 211 + </svg> 212 + <span class="text-xs">Upload image</span> 213 + </button> 214 + {/if} 215 + </div> 216 + 217 + <!-- Name --> 218 + <div> 219 + <label 220 + class="text-base-700 dark:text-base-300 mb-1.5 block text-sm font-medium" 221 + for="name" 222 + > 223 + Name <span class="text-red-500">*</span> 224 + </label> 225 + <input 226 + type="text" 227 + id="name" 228 + bind:value={name} 229 + required 230 + placeholder="Event name" 231 + class="border-base-300 dark:border-base-700 bg-base-50 dark:bg-base-900 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" 232 + /> 233 + </div> 234 + 235 + <!-- Description --> 236 + <div> 237 + <label 238 + class="text-base-700 dark:text-base-300 mb-1.5 block text-sm font-medium" 239 + for="description" 240 + > 241 + Description 242 + </label> 243 + <textarea 244 + id="description" 245 + bind:value={description} 246 + rows={4} 247 + placeholder="What's this event about?" 248 + class="border-base-300 dark:border-base-700 bg-base-50 dark:bg-base-900 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" 249 + ></textarea> 250 + </div> 251 + 252 + <!-- Start date/time --> 253 + <div> 254 + <label 255 + class="text-base-700 dark:text-base-300 mb-1.5 block text-sm font-medium" 256 + for="startsAt" 257 + > 258 + Start date & time <span class="text-red-500">*</span> 259 + </label> 260 + <input 261 + type="datetime-local" 262 + id="startsAt" 263 + bind:value={startsAt} 264 + required 265 + class="border-base-300 dark:border-base-700 bg-base-50 dark:bg-base-900 text-base-900 dark:text-base-100 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" 266 + /> 267 + </div> 268 + 269 + <!-- End date/time --> 270 + <div> 271 + <label 272 + class="text-base-700 dark:text-base-300 mb-1.5 block text-sm font-medium" 273 + for="endsAt" 274 + > 275 + End date & time 276 + </label> 277 + <input 278 + type="datetime-local" 279 + id="endsAt" 280 + bind:value={endsAt} 281 + class="border-base-300 dark:border-base-700 bg-base-50 dark:bg-base-900 text-base-900 dark:text-base-100 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" 282 + /> 283 + </div> 284 + 285 + {#if error} 286 + <p class="text-sm text-red-600 dark:text-red-400">{error}</p> 287 + {/if} 288 + 289 + <Button type="submit" disabled={submitting} class="w-full"> 290 + {submitting ? 'Creating...' : 'Create Event'} 291 + </Button> 292 + </form> 293 + {/if} 294 + </div> 295 + </div>