your personal website on atproto - mirror blento.app
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

events creation

Florian 92c22924 23e1a609

+1015 -276
+2 -1
src/lib/atproto/settings.ts
··· 24 24 'site.standard.publication', 25 25 'site.standard.document', 26 26 'xyz.statusphere.status', 27 - 'community.lexicon.calendar.rsvp' 27 + 'community.lexicon.calendar.rsvp', 28 + 'community.lexicon.calendar.event' 28 29 ], 29 30 30 31 // what types of authenticated proxied requests you can make to services
+4
src/lib/cards/social/EventCard/index.ts
··· 36 36 height: number; 37 37 }; 38 38 }>; 39 + facets?: Array<{ 40 + index: { byteStart: number; byteEnd: number }; 41 + features: Array<{ $type: string; [key: string]: unknown }>; 42 + }>; 39 43 uris?: Array<{ 40 44 uri: string; 41 45 name?: string;
+58
src/lib/components/image-store.ts
··· 1 + const DB_NAME = 'blento-images'; 2 + const STORE_NAME = 'images'; 3 + 4 + function openDB(): Promise<IDBDatabase> { 5 + return new Promise((resolve, reject) => { 6 + const request = indexedDB.open(DB_NAME, 1); 7 + request.onupgradeneeded = () => { 8 + request.result.createObjectStore(STORE_NAME); 9 + }; 10 + request.onsuccess = () => resolve(request.result); 11 + request.onerror = () => reject(request.error); 12 + }); 13 + } 14 + 15 + export async function putImage(key: string, blob: Blob, name: string): Promise<void> { 16 + const db = await openDB(); 17 + const data = await blob.arrayBuffer(); 18 + return new Promise((resolve, reject) => { 19 + const tx = db.transaction(STORE_NAME, 'readwrite'); 20 + tx.objectStore(STORE_NAME).put({ data, type: blob.type, name }, key); 21 + tx.oncomplete = () => resolve(); 22 + tx.onerror = () => reject(tx.error); 23 + }); 24 + } 25 + 26 + export async function getImage(key: string): Promise<{ blob: Blob; name: string } | null> { 27 + const db = await openDB(); 28 + return new Promise((resolve, reject) => { 29 + const tx = db.transaction(STORE_NAME, 'readonly'); 30 + const request = tx.objectStore(STORE_NAME).get(key); 31 + request.onsuccess = () => { 32 + if (!request.result) return resolve(null); 33 + const { data, type, name } = request.result; 34 + resolve({ blob: new Blob([data], { type }), name }); 35 + }; 36 + request.onerror = () => reject(request.error); 37 + }); 38 + } 39 + 40 + export async function deleteImage(key: string): Promise<void> { 41 + const db = await openDB(); 42 + return new Promise((resolve, reject) => { 43 + const tx = db.transaction(STORE_NAME, 'readwrite'); 44 + tx.objectStore(STORE_NAME).delete(key); 45 + tx.oncomplete = () => resolve(); 46 + tx.onerror = () => reject(tx.error); 47 + }); 48 + } 49 + 50 + export async function clearImages(): Promise<void> { 51 + const db = await openDB(); 52 + return new Promise((resolve, reject) => { 53 + const tx = db.transaction(STORE_NAME, 'readwrite'); 54 + tx.objectStore(STORE_NAME).clear(); 55 + tx.oncomplete = () => resolve(); 56 + tx.onerror = () => reject(tx.error); 57 + }); 58 + }
+345 -134
src/routes/[[actor=actor]]/blog/new/+page.svelte
··· 5 5 import { compressImage } from '$lib/atproto/image-helper'; 6 6 import { Button } from '@foxui/core'; 7 7 import { goto } from '$app/navigation'; 8 + import { onMount, onDestroy } from 'svelte'; 9 + import { SvelteMap } from 'svelte/reactivity'; 10 + import { RichTextEditor } from '$lib/components/rich-text-editor'; 11 + import { putImage, getImage, clearImages } from '$lib/components/image-store'; 12 + import type { Editor, Content } from '@tiptap/core'; 13 + 14 + const DRAFT_KEY = 'blog-draft'; 8 15 9 16 let title = $state(''); 10 17 let description = $state(''); 11 - let content = $state(''); 12 18 let coverFile: File | null = $state(null); 13 19 let coverPreview: string | null = $state(null); 14 20 let submitting = $state(false); 15 21 let error: string | null = $state(null); 22 + let hasDraft = $state(false); 23 + let editorInstance: Editor | null = $state(null); 24 + let editorContent: Content = $state({}); 25 + let draftRestored = $state(false); 16 26 17 27 let fileInput: HTMLInputElement | undefined = $state(); 18 28 29 + // blob URL <-> IndexedDB key mappings 30 + const blobToKeyMap = new SvelteMap<string, string>(); 31 + const keyToBlobMap = new SvelteMap<string, string>(); 32 + 33 + // ── Image tracking ── 34 + 35 + function findImageSrcs(node: unknown): string[] { 36 + const srcs: string[] = []; 37 + function walk(n: Record<string, unknown>) { 38 + if (!n) return; 39 + if (n.type === 'image' && typeof (n.attrs as Record<string, unknown>)?.src === 'string') { 40 + srcs.push((n.attrs as Record<string, string>).src); 41 + } 42 + if (Array.isArray(n.content)) (n.content as Record<string, unknown>[]).forEach(walk); 43 + } 44 + walk(node as Record<string, unknown>); 45 + return srcs; 46 + } 47 + 48 + async function trackNewImages(content: Content) { 49 + const srcs = findImageSrcs(content); 50 + for (const src of srcs) { 51 + if (src.startsWith('blob:') && !blobToKeyMap.has(src)) { 52 + try { 53 + const response = await fetch(src); 54 + const blob = await response.blob(); 55 + const key = crypto.randomUUID(); 56 + await putImage(key, blob, 'image'); 57 + blobToKeyMap.set(src, key); 58 + keyToBlobMap.set(key, src); 59 + } catch (e) { 60 + console.error('Failed to store image in IndexedDB:', e); 61 + } 62 + } 63 + } 64 + } 65 + 66 + // ── Draft management ── 67 + 68 + function serializeContent(content: Content): Content { 69 + const json = JSON.parse(JSON.stringify(content)); 70 + function walk(node: Record<string, unknown>) { 71 + if (!node) return; 72 + const attrs = node.attrs as Record<string, string> | undefined; 73 + if (node.type === 'image' && attrs?.src && blobToKeyMap.has(attrs.src)) { 74 + attrs.src = `idb://${blobToKeyMap.get(attrs.src)}`; 75 + } 76 + if (Array.isArray(node.content)) (node.content as Record<string, unknown>[]).forEach(walk); 77 + } 78 + walk(json as Record<string, unknown>); 79 + return json; 80 + } 81 + 82 + let saveTimeout: ReturnType<typeof setTimeout> | undefined; 83 + 84 + function scheduleSaveDraft() { 85 + clearTimeout(saveTimeout); 86 + saveTimeout = setTimeout(saveDraft, 500); 87 + } 88 + 89 + function saveDraft() { 90 + const draft: Record<string, unknown> = { 91 + title, 92 + description, 93 + content: serializeContent(editorContent), 94 + updatedAt: Date.now() 95 + }; 96 + if (coverPreview && blobToKeyMap.has(coverPreview)) { 97 + draft.coverImageKey = blobToKeyMap.get(coverPreview); 98 + } 99 + localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); 100 + hasDraft = true; 101 + } 102 + 103 + async function restoreDraft() { 104 + const raw = localStorage.getItem(DRAFT_KEY); 105 + if (!raw) return; 106 + 107 + try { 108 + const draft = JSON.parse(raw); 109 + title = draft.title || ''; 110 + description = draft.description || ''; 111 + 112 + const content = draft.content; 113 + if (content) { 114 + // Collect all idb:// keys needed 115 + const idbKeys: string[] = []; 116 + function collectKeys(node: Record<string, unknown>) { 117 + if (!node) return; 118 + const attrs = node.attrs as Record<string, string> | undefined; 119 + if (node.type === 'image' && attrs?.src?.startsWith('idb://')) { 120 + idbKeys.push(attrs.src.replace('idb://', '')); 121 + } 122 + if (Array.isArray(node.content)) 123 + (node.content as Record<string, unknown>[]).forEach(collectKeys); 124 + } 125 + collectKeys(content); 126 + if (draft.coverImageKey) idbKeys.push(draft.coverImageKey); 127 + 128 + // Load images from IndexedDB and create blob URLs 129 + for (const key of idbKeys) { 130 + const img = await getImage(key); 131 + if (img) { 132 + const blobUrl = URL.createObjectURL(img.blob); 133 + blobToKeyMap.set(blobUrl, key); 134 + keyToBlobMap.set(key, blobUrl); 135 + } 136 + } 137 + 138 + // Replace idb:// references with new blob URLs 139 + function replaceRefs(node: Record<string, unknown>) { 140 + if (!node) return; 141 + const attrs = node.attrs as Record<string, string> | undefined; 142 + if (node.type === 'image' && attrs?.src?.startsWith('idb://')) { 143 + const key = attrs.src.replace('idb://', ''); 144 + const blobUrl = keyToBlobMap.get(key); 145 + if (blobUrl) attrs.src = blobUrl; 146 + } 147 + if (Array.isArray(node.content)) 148 + (node.content as Record<string, unknown>[]).forEach(replaceRefs); 149 + } 150 + replaceRefs(content); 151 + editorContent = content; 152 + } 153 + 154 + // Restore cover image 155 + if (draft.coverImageKey) { 156 + const blobUrl = keyToBlobMap.get(draft.coverImageKey); 157 + if (blobUrl) { 158 + coverPreview = blobUrl; 159 + const img = await getImage(draft.coverImageKey); 160 + if (img) { 161 + coverFile = new File([img.blob], img.name, { type: img.blob.type }); 162 + } 163 + } 164 + } 165 + 166 + hasDraft = true; 167 + } catch (e) { 168 + console.error('Failed to restore draft:', e); 169 + } 170 + } 171 + 172 + function clearDraft() { 173 + localStorage.removeItem(DRAFT_KEY); 174 + clearImages(); 175 + hasDraft = false; 176 + } 177 + 178 + function discardDraft() { 179 + clearDraft(); 180 + title = ''; 181 + description = ''; 182 + coverFile = null; 183 + if (coverPreview) URL.revokeObjectURL(coverPreview); 184 + coverPreview = null; 185 + editorContent = {}; 186 + editorInstance?.commands.clearContent(); 187 + } 188 + 189 + // ── Cover image ── 190 + 19 191 function onFileChange(e: Event) { 20 192 const input = e.target as HTMLInputElement; 21 193 const file = input.files?.[0]; ··· 23 195 coverFile = file; 24 196 if (coverPreview) URL.revokeObjectURL(coverPreview); 25 197 coverPreview = URL.createObjectURL(file); 198 + 199 + const key = crypto.randomUUID(); 200 + blobToKeyMap.set(coverPreview, key); 201 + keyToBlobMap.set(key, coverPreview); 202 + putImage(key, file, file.name); 203 + scheduleSaveDraft(); 26 204 } 27 205 28 206 function removeCover() { ··· 32 210 coverPreview = null; 33 211 } 34 212 if (fileInput) fileInput.value = ''; 213 + scheduleSaveDraft(); 35 214 } 215 + 216 + // ── Publish ── 36 217 37 218 async function handleSubmit() { 38 219 error = null; ··· 49 230 submitting = true; 50 231 51 232 try { 52 - let coverImage: unknown | undefined; 53 - 233 + // Upload cover image 234 + let coverImageBlob: unknown | undefined; 54 235 if (coverFile) { 55 236 const compressed = await compressImage(coverFile); 56 237 const blobRef = await uploadBlob({ blob: compressed.blob }); 57 - if (blobRef) { 58 - coverImage = blobRef; 238 + if (blobRef) coverImageBlob = blobRef; 239 + } 240 + 241 + // Convert content to markdown using tiptap's built-in markdown support 242 + let markdown = editorInstance?.getMarkdown() ?? ''; 243 + 244 + // Upload all blob:// images and replace with CDN URLs 245 + const blobUrlRegex = /!\[([^\]]*)\]\((blob:[^)]+)\)/g; 246 + const matches = [...markdown.matchAll(blobUrlRegex)]; 247 + 248 + for (const match of matches) { 249 + const blobUrl = match[2]; 250 + try { 251 + const response = await fetch(blobUrl); 252 + const blob = await response.blob(); 253 + const file = new File([blob], 'image.jpg', { type: blob.type }); 254 + const compressed = await compressImage(file); 255 + const blobRef = await uploadBlob({ blob: compressed.blob }); 256 + if (blobRef) { 257 + const cdnUrl = `https://cdn.bsky.app/img/feed_fullsize/plain/${user.did}/${blobRef.ref.$link}@jpeg`; 258 + markdown = markdown.replaceAll(blobUrl, cdnUrl); 259 + } 260 + } catch (e) { 261 + console.error('Failed to upload inline image:', e); 59 262 } 60 263 } 61 264 ··· 64 267 const record: Record<string, unknown> = { 65 268 $type: 'site.standard.document', 66 269 title: title.trim(), 67 - content: { $type: 'app.blento.markdown', value: content }, 270 + content: { $type: 'app.blento.markdown', value: markdown }, 68 271 site: `at://${user.did}/site.standard.publication/blento.self`, 69 272 path: `/blog/${rkey}`, 70 273 publishedAt: new Date().toISOString() 71 274 }; 72 275 73 - if (description.trim()) { 74 - record.description = description.trim(); 75 - } 76 - if (coverImage) { 77 - record.coverImage = coverImage; 78 - } 276 + if (description.trim()) record.description = description.trim(); 277 + if (coverImageBlob) record.coverImage = coverImageBlob; 79 278 80 279 const response = await user.client.post('com.atproto.repo.createRecord', { 81 280 input: { ··· 87 286 }); 88 287 89 288 if (response.ok) { 289 + clearDraft(); 90 290 const handle = 91 291 user.profile?.handle && user.profile.handle !== 'handle.invalid' 92 292 ? user.profile.handle ··· 102 302 submitting = false; 103 303 } 104 304 } 305 + 306 + // ── Editor callbacks ── 307 + 308 + function onEditorUpdate(content: Content) { 309 + editorContent = content; 310 + trackNewImages(content); 311 + scheduleSaveDraft(); 312 + } 313 + 314 + function onTitleInput() { 315 + scheduleSaveDraft(); 316 + } 317 + 318 + function onDescriptionInput() { 319 + scheduleSaveDraft(); 320 + } 321 + 322 + // ── Lifecycle ── 323 + 324 + onMount(async () => { 325 + await restoreDraft(); 326 + draftRestored = true; 327 + }); 328 + 329 + onDestroy(() => { 330 + clearTimeout(saveTimeout); 331 + for (const blobUrl of blobToKeyMap.keys()) { 332 + URL.revokeObjectURL(blobUrl); 333 + } 334 + }); 105 335 </script> 106 336 107 337 <svelte:head> ··· 109 339 </svelte:head> 110 340 111 341 <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12"> 112 - <div class="mx-auto max-w-2xl"> 113 - <h1 class="text-base-900 dark:text-base-50 mb-8 text-3xl font-bold">Create Blog Post</h1> 114 - 115 - {#if user.isInitializing} 342 + <div class="mx-auto max-w-3xl"> 343 + {#if user.isInitializing || !draftRestored} 116 344 <div class="flex items-center gap-3"> 117 345 <div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div> 118 346 <div class="bg-base-300 dark:bg-base-700 h-4 w-48 animate-pulse rounded"></div> ··· 125 353 <Button onclick={() => loginModalState.show()}>Log in</Button> 126 354 </div> 127 355 {:else} 128 - <form 129 - onsubmit={(e) => { 130 - e.preventDefault(); 131 - handleSubmit(); 132 - }} 133 - class="space-y-6" 134 - > 135 - <!-- Cover image --> 136 - <div> 137 - <label 138 - class="text-base-700 dark:text-base-300 mb-1.5 block text-sm font-medium" 139 - for="cover" 356 + <!-- Draft badge --> 357 + {#if hasDraft} 358 + <div class="mb-6 flex items-center gap-3"> 359 + <span 360 + class="rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-300" 140 361 > 141 - Cover image 142 - </label> 143 - <input 144 - bind:this={fileInput} 145 - type="file" 146 - id="cover" 147 - accept="image/*" 148 - onchange={onFileChange} 149 - class="hidden" 362 + Draft 363 + </span> 364 + <button 365 + type="button" 366 + onclick={discardDraft} 367 + class="text-base-400 dark:text-base-500 text-xs transition-colors hover:text-red-500 dark:hover:text-red-400" 368 + > 369 + Discard draft 370 + </button> 371 + </div> 372 + {/if} 373 + 374 + <!-- Cover image — full-width like the blog post view --> 375 + <input 376 + bind:this={fileInput} 377 + type="file" 378 + id="cover" 379 + accept="image/*" 380 + onchange={onFileChange} 381 + class="hidden" 382 + /> 383 + {#if coverPreview} 384 + <div class="group relative mb-8"> 385 + <img 386 + src={coverPreview} 387 + alt="Cover preview" 388 + class="aspect-video w-full rounded-2xl object-cover" 150 389 /> 151 - {#if coverPreview} 152 - <div class="relative inline-block"> 153 - <img 154 - src={coverPreview} 155 - alt="Cover preview" 156 - class="border-base-200 dark:border-base-700 h-40 w-40 rounded-xl border object-cover" 157 - /> 158 - <button 159 - type="button" 160 - onclick={removeCover} 161 - aria-label="Remove cover image" 162 - 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" 163 - > 164 - <svg 165 - xmlns="http://www.w3.org/2000/svg" 166 - viewBox="0 0 20 20" 167 - fill="currentColor" 168 - class="size-3.5" 169 - > 170 - <path 171 - 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" 172 - /> 173 - </svg> 174 - </button> 175 - </div> 176 - {:else} 390 + <div 391 + class="absolute inset-0 flex items-center justify-center gap-2 rounded-2xl bg-black/0 opacity-0 transition-all group-hover:bg-black/30 group-hover:opacity-100" 392 + > 177 393 <button 178 394 type="button" 179 395 onclick={() => fileInput?.click()} 180 - 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" 396 + class="rounded-lg bg-white/90 px-3 py-1.5 text-sm font-medium text-black transition-colors hover:bg-white" 181 397 > 182 - <svg 183 - xmlns="http://www.w3.org/2000/svg" 184 - fill="none" 185 - viewBox="0 0 24 24" 186 - stroke-width="1.5" 187 - stroke="currentColor" 188 - class="mb-1 size-6" 189 - > 190 - <path 191 - stroke-linecap="round" 192 - stroke-linejoin="round" 193 - 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" 194 - /> 195 - </svg> 196 - <span class="text-xs">Upload image</span> 398 + Replace 399 + </button> 400 + <button 401 + type="button" 402 + onclick={removeCover} 403 + class="rounded-lg bg-white/90 px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-white" 404 + > 405 + Remove 197 406 </button> 198 - {/if} 407 + </div> 199 408 </div> 200 - 201 - <!-- Title --> 202 - <div> 203 - <label 204 - class="text-base-700 dark:text-base-300 mb-1.5 block text-sm font-medium" 205 - for="title" 409 + {:else} 410 + <button 411 + type="button" 412 + onclick={() => fileInput?.click()} 413 + class="border-base-300 dark:border-base-700 hover:border-base-400 dark:hover:border-base-600 text-base-400 dark:text-base-500 mb-8 flex aspect-video w-full flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-colors" 414 + > 415 + <svg 416 + xmlns="http://www.w3.org/2000/svg" 417 + fill="none" 418 + viewBox="0 0 24 24" 419 + stroke-width="1.5" 420 + stroke="currentColor" 421 + class="mb-2 size-8" 206 422 > 207 - Title <span class="text-red-500">*</span> 208 - </label> 209 - <input 210 - type="text" 211 - id="title" 212 - bind:value={title} 213 - required 214 - placeholder="Post title" 215 - 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" 216 - /> 217 - </div> 423 + <path 424 + stroke-linecap="round" 425 + stroke-linejoin="round" 426 + 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" 427 + /> 428 + </svg> 429 + <span class="text-sm">Add cover image</span> 430 + </button> 431 + {/if} 218 432 219 - <!-- Description --> 220 - <div> 221 - <label 222 - class="text-base-700 dark:text-base-300 mb-1.5 block text-sm font-medium" 223 - for="description" 224 - > 225 - Description 226 - </label> 227 - <textarea 228 - id="description" 229 - bind:value={description} 230 - rows={2} 231 - placeholder="A short summary of the post" 232 - 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" 233 - ></textarea> 234 - </div> 433 + <!-- Title & description — styled like the blog post header --> 434 + <header class="mb-8"> 435 + <input 436 + type="text" 437 + bind:value={title} 438 + oninput={onTitleInput} 439 + placeholder="Post title" 440 + class="text-base-900 dark:text-base-50 placeholder:text-base-300 dark:placeholder:text-base-700 mb-4 w-full border-none bg-transparent text-3xl leading-tight font-bold outline-none sm:text-4xl" 441 + /> 442 + <textarea 443 + bind:value={description} 444 + oninput={onDescriptionInput} 445 + placeholder="A short description (optional)" 446 + rows={1} 447 + class="text-base-500 dark:text-base-400 placeholder:text-base-300 dark:placeholder:text-base-700 w-full resize-none border-none bg-transparent text-sm leading-relaxed outline-none" 448 + ></textarea> 449 + </header> 235 450 236 - <!-- Content --> 237 - <div> 238 - <label 239 - class="text-base-700 dark:text-base-300 mb-1.5 block text-sm font-medium" 240 - for="content" 241 - > 242 - Content 243 - </label> 244 - <textarea 245 - id="content" 246 - bind:value={content} 247 - rows={12} 248 - placeholder="Write your post content in markdown..." 249 - 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 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" 250 - ></textarea> 251 - </div> 451 + <!-- Rich text editor — styled like the blog post content area --> 452 + <article 453 + class="prose dark:prose-invert prose-base prose-neutral prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-img:rounded-xl mb-12 max-w-none" 454 + > 455 + <RichTextEditor 456 + bind:editor={editorInstance} 457 + content={editorContent} 458 + placeholder="Start writing..." 459 + onupdate={onEditorUpdate} 460 + /> 461 + </article> 252 462 253 - {#if error} 254 - <p class="text-sm text-red-600 dark:text-red-400">{error}</p> 255 - {/if} 463 + {#if error} 464 + <p class="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p> 465 + {/if} 256 466 257 - <Button type="submit" disabled={submitting} class="w-full"> 467 + <div class="border-base-200 dark:border-base-800 border-t pt-6"> 468 + <Button onclick={handleSubmit} disabled={submitting} class="w-full"> 258 469 {submitting ? 'Publishing...' : 'Publish Post'} 259 470 </Button> 260 - </form> 471 + </div> 261 472 {/if} 262 473 </div> 263 474 </div>
+49 -4
src/routes/[[actor=actor]]/events/[rkey]/+page.svelte
··· 5 5 import Avatar from 'svelte-boring-avatars'; 6 6 import EventRsvp from './EventRsvp.svelte'; 7 7 import { page } from '$app/state'; 8 + import { segmentize, type Facet } from '@atcute/bluesky-richtext-segmenter'; 9 + import { sanitize } from '$lib/sanitize'; 8 10 9 11 let { data } = $props(); 10 12 ··· 108 110 startDate.getDate() === endDate.getDate() 109 111 ); 110 112 113 + function escapeHtml(str: string): string { 114 + return str 115 + .replace(/&/g, '&amp;') 116 + .replace(/</g, '&lt;') 117 + .replace(/>/g, '&gt;') 118 + .replace(/"/g, '&quot;') 119 + .replace(/'/g, '&#39;'); 120 + } 121 + 122 + function renderDescription(text: string, facets?: Facet[]): string { 123 + const segments = segmentize(text, facets); 124 + const html = segments 125 + .map((segment) => { 126 + const escaped = escapeHtml(segment.text); 127 + const feature = segment.features?.[0] as 128 + | { $type: string; did?: string; uri?: string; tag?: string } 129 + | undefined; 130 + if (!feature) return `<span>${escaped}</span>`; 131 + 132 + const link = (href: string) => 133 + `<a target="_blank" rel="noopener noreferrer nofollow" href="${encodeURI(href)}" class="text-accent-600 dark:text-accent-400 hover:underline">${escaped}</a>`; 134 + 135 + switch (feature.$type) { 136 + case 'app.bsky.richtext.facet#mention': 137 + return link(`https://bsky.app/profile/${feature.did}`); 138 + case 'app.bsky.richtext.facet#link': 139 + return link(feature.uri!); 140 + case 'app.bsky.richtext.facet#tag': 141 + return link(`https://bsky.app/hashtag/${feature.tag}`); 142 + default: 143 + return `<span>${escaped}</span>`; 144 + } 145 + }) 146 + .join(''); 147 + return html.replace(/\n/g, '<br>'); 148 + } 149 + 150 + let descriptionHtml = $derived( 151 + eventData.description 152 + ? sanitize(renderDescription(eventData.description, eventData.facets as Facet[] | undefined)) 153 + : null 154 + ); 155 + 111 156 let smokesignalUrl = $derived(`https://smokesignal.events/${did}/${rkey}`); 112 157 let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`); 113 158 ··· 133 178 <img 134 179 src={displayImage.url} 135 180 alt={displayImage.alt} 136 - class="border-base-200 dark:border-base-800 mb-8 aspect-[3/1] w-full rounded-2xl border object-cover" 181 + class="border-base-200 dark:border-base-800 mb-8 aspect-3/1 w-full rounded-2xl border object-cover" 137 182 /> 138 183 {/if} 139 184 ··· 244 289 <EventRsvp {eventUri} eventCid={data.eventCid} /> 245 290 246 291 <!-- About Event --> 247 - {#if eventData.description} 292 + {#if descriptionHtml} 248 293 <div class="mt-8 mb-8"> 249 294 <p 250 295 class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 251 296 > 252 297 About 253 298 </p> 254 - <p class="text-base-700 dark:text-base-300 leading-relaxed whitespace-pre-wrap"> 255 - {eventData.description} 299 + <p class="text-base-700 dark:text-base-300 leading-relaxed"> 300 + {@html descriptionHtml} 256 301 </p> 257 302 </div> 258 303 {/if}
+557 -137
src/routes/[[actor=actor]]/events/new/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { user } from '$lib/atproto/auth.svelte'; 3 3 import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 4 - import { uploadBlob } from '$lib/atproto/methods'; 4 + import { uploadBlob, resolveHandle } from '$lib/atproto/methods'; 5 5 import { compressImage } from '$lib/atproto/image-helper'; 6 - import { Button } from '@foxui/core'; 6 + import { Avatar as FoxAvatar, Badge, Button, ToggleGroup, ToggleGroupItem } from '@foxui/core'; 7 7 import { goto } from '$app/navigation'; 8 + import { tokenize, type Token } from '@atcute/bluesky-richtext-parser'; 9 + import type { Handle } from '@atcute/lexicons'; 10 + import { onMount } from 'svelte'; 11 + import { browser } from '$app/environment'; 12 + import { putImage, getImage, deleteImage } from '$lib/components/image-store'; 13 + 14 + const DRAFT_KEY = 'blento-event-draft'; 15 + 16 + type EventMode = 'inperson' | 'virtual' | 'hybrid'; 17 + 18 + interface EventDraft { 19 + name: string; 20 + description: string; 21 + startsAt: string; 22 + endsAt: string; 23 + links: Array<{ uri: string; name: string }>; 24 + mode?: EventMode; 25 + thumbnailKey?: string; 26 + } 27 + 28 + let thumbnailKey: string | null = $state(null); 8 29 9 30 let name = $state(''); 10 31 let description = $state(''); 11 32 let startsAt = $state(''); 12 33 let endsAt = $state(''); 34 + let mode: EventMode = $state('inperson'); 13 35 let thumbnailFile: File | null = $state(null); 14 36 let thumbnailPreview: string | null = $state(null); 15 37 let submitting = $state(false); 16 38 let error: string | null = $state(null); 17 39 40 + let links: Array<{ uri: string; name: string }> = $state([]); 41 + let showLinkPopup = $state(false); 42 + let newLinkUri = $state(''); 43 + let newLinkName = $state(''); 44 + 45 + let hasDraft = $state(false); 46 + let draftLoaded = $state(false); 47 + 48 + onMount(async () => { 49 + const saved = localStorage.getItem(DRAFT_KEY); 50 + if (saved) { 51 + try { 52 + const draft: EventDraft = JSON.parse(saved); 53 + name = draft.name || ''; 54 + description = draft.description || ''; 55 + startsAt = draft.startsAt || ''; 56 + endsAt = draft.endsAt || ''; 57 + links = draft.links || []; 58 + mode = draft.mode || 'inperson'; 59 + 60 + if (draft.thumbnailKey) { 61 + const img = await getImage(draft.thumbnailKey); 62 + if (img) { 63 + thumbnailKey = draft.thumbnailKey; 64 + thumbnailFile = new File([img.blob], img.name, { type: img.blob.type }); 65 + thumbnailPreview = URL.createObjectURL(img.blob); 66 + } 67 + } 68 + 69 + hasDraft = true; 70 + } catch { 71 + localStorage.removeItem(DRAFT_KEY); 72 + } 73 + } 74 + draftLoaded = true; 75 + }); 76 + 77 + let saveDraftTimeout: ReturnType<typeof setTimeout> | undefined; 78 + 79 + function saveDraft() { 80 + if (!draftLoaded || !browser) return; 81 + clearTimeout(saveDraftTimeout); 82 + saveDraftTimeout = setTimeout(() => { 83 + const draft: EventDraft = { name, description, startsAt, endsAt, links, mode }; 84 + if (thumbnailKey) draft.thumbnailKey = thumbnailKey; 85 + const hasContent = name || description || startsAt || endsAt || links.length > 0; 86 + if (hasContent) { 87 + localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); 88 + hasDraft = true; 89 + } else { 90 + localStorage.removeItem(DRAFT_KEY); 91 + hasDraft = false; 92 + } 93 + }, 500); 94 + } 95 + 96 + $effect(() => { 97 + // track all draft fields by reading them 98 + void [name, description, startsAt, endsAt, mode, JSON.stringify(links)]; 99 + saveDraft(); 100 + }); 101 + 102 + function deleteDraft() { 103 + localStorage.removeItem(DRAFT_KEY); 104 + if (thumbnailKey) deleteImage(thumbnailKey); 105 + name = ''; 106 + description = ''; 107 + startsAt = ''; 108 + endsAt = ''; 109 + links = []; 110 + mode = 'inperson'; 111 + thumbnailFile = null; 112 + if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 113 + thumbnailPreview = null; 114 + thumbnailKey = null; 115 + hasDraft = false; 116 + } 117 + 118 + function addLink() { 119 + const uri = newLinkUri.trim(); 120 + if (!uri) return; 121 + links.push({ uri, name: newLinkName.trim() }); 122 + newLinkUri = ''; 123 + newLinkName = ''; 124 + showLinkPopup = false; 125 + } 126 + 127 + function removeLink(index: number) { 128 + links.splice(index, 1); 129 + } 130 + 18 131 let fileInput: HTMLInputElement | undefined = $state(); 19 132 20 - function onFileChange(e: Event) { 133 + let hostName = $derived(user.profile?.displayName || user.profile?.handle || user.did || ''); 134 + 135 + async function setThumbnail(file: File) { 136 + thumbnailFile = file; 137 + if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 138 + thumbnailPreview = URL.createObjectURL(file); 139 + 140 + if (thumbnailKey) await deleteImage(thumbnailKey); 141 + thumbnailKey = crypto.randomUUID(); 142 + await putImage(thumbnailKey, file, file.name); 143 + saveDraft(); 144 + } 145 + 146 + async function onFileChange(e: Event) { 21 147 const input = e.target as HTMLInputElement; 22 148 const file = input.files?.[0]; 23 149 if (!file) return; 24 - thumbnailFile = file; 25 - if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 26 - thumbnailPreview = URL.createObjectURL(file); 150 + setThumbnail(file); 151 + } 152 + 153 + let isDragOver = $state(false); 154 + 155 + function onDragOver(e: DragEvent) { 156 + e.preventDefault(); 157 + isDragOver = true; 158 + } 159 + 160 + function onDragLeave(e: DragEvent) { 161 + e.preventDefault(); 162 + isDragOver = false; 163 + } 164 + 165 + function onDrop(e: DragEvent) { 166 + e.preventDefault(); 167 + isDragOver = false; 168 + const file = e.dataTransfer?.files?.[0]; 169 + if (file?.type.startsWith('image/')) { 170 + setThumbnail(file); 171 + } 27 172 } 28 173 29 174 function removeThumbnail() { ··· 32 177 URL.revokeObjectURL(thumbnailPreview); 33 178 thumbnailPreview = null; 34 179 } 180 + if (thumbnailKey) { 181 + deleteImage(thumbnailKey); 182 + thumbnailKey = null; 183 + } 35 184 if (fileInput) fileInput.value = ''; 185 + saveDraft(); 186 + } 187 + 188 + function formatMonth(date: Date): string { 189 + return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); 190 + } 191 + 192 + function formatDay(date: Date): number { 193 + return date.getDate(); 194 + } 195 + 196 + function formatWeekday(date: Date): string { 197 + return date.toLocaleDateString('en-US', { weekday: 'long' }); 198 + } 199 + 200 + function formatFullDate(date: Date): string { 201 + const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' }; 202 + if (date.getFullYear() !== new Date().getFullYear()) { 203 + options.year = 'numeric'; 204 + } 205 + return date.toLocaleDateString('en-US', options); 206 + } 207 + 208 + function formatTime(date: Date): string { 209 + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); 210 + } 211 + 212 + let startDate = $derived(startsAt ? new Date(startsAt) : null); 213 + let endDate = $derived(endsAt ? new Date(endsAt) : null); 214 + let isSameDay = $derived( 215 + startDate && 216 + endDate && 217 + startDate.getFullYear() === endDate.getFullYear() && 218 + startDate.getMonth() === endDate.getMonth() && 219 + startDate.getDate() === endDate.getDate() 220 + ); 221 + 222 + async function tokensToFacets(tokens: Token[]): Promise<Record<string, unknown>[]> { 223 + const encoder = new TextEncoder(); 224 + const facets: Record<string, unknown>[] = []; 225 + let byteOffset = 0; 226 + 227 + for (const token of tokens) { 228 + const tokenBytes = encoder.encode(token.raw); 229 + const byteStart = byteOffset; 230 + const byteEnd = byteOffset + tokenBytes.length; 231 + 232 + if (token.type === 'mention') { 233 + try { 234 + const did = await resolveHandle({ handle: token.handle as Handle }); 235 + if (did) { 236 + facets.push({ 237 + index: { byteStart, byteEnd }, 238 + features: [{ $type: 'app.bsky.richtext.facet#mention', did }] 239 + }); 240 + } 241 + } catch { 242 + // skip unresolvable mentions 243 + } 244 + } else if (token.type === 'autolink') { 245 + facets.push({ 246 + index: { byteStart, byteEnd }, 247 + features: [{ $type: 'app.bsky.richtext.facet#link', uri: token.url }] 248 + }); 249 + } else if (token.type === 'topic') { 250 + facets.push({ 251 + index: { byteStart, byteEnd }, 252 + features: [{ $type: 'app.bsky.richtext.facet#tag', tag: token.name }] 253 + }); 254 + } 255 + 256 + byteOffset = byteEnd; 257 + } 258 + 259 + return facets; 36 260 } 37 261 38 262 async function handleSubmit() { ··· 76 300 const record: Record<string, unknown> = { 77 301 $type: 'community.lexicon.calendar.event', 78 302 name: name.trim(), 79 - mode: 'community.lexicon.calendar.event#inperson', 303 + mode: `community.lexicon.calendar.event#${mode}`, 80 304 status: 'community.lexicon.calendar.event#scheduled', 81 305 startsAt: new Date(startsAt).toISOString(), 82 306 createdAt: new Date().toISOString() 83 307 }; 84 308 85 - if (description.trim()) { 86 - record.description = description.trim(); 309 + const trimmedDescription = description.trim(); 310 + if (trimmedDescription) { 311 + record.description = trimmedDescription; 312 + const tokens = tokenize(trimmedDescription); 313 + const facets = await tokensToFacets(tokens); 314 + if (facets.length > 0) { 315 + record.facets = facets; 316 + } 87 317 } 88 318 if (endsAt) { 89 319 record.endsAt = new Date(endsAt).toISOString(); 90 320 } 91 321 if (media) { 92 322 record.media = media; 323 + } 324 + if (links.length > 0) { 325 + record.uris = links; 93 326 } 94 327 95 328 const response = await user.client.post('com.atproto.repo.createRecord', { ··· 101 334 }); 102 335 103 336 if (response.ok) { 337 + localStorage.removeItem(DRAFT_KEY); 338 + if (thumbnailKey) deleteImage(thumbnailKey); 104 339 const parts = response.data.uri.split('/'); 105 340 const rkey = parts[parts.length - 1]; 106 341 const handle = 107 342 user.profile?.handle && user.profile.handle !== 'handle.invalid' 108 343 ? user.profile.handle 109 344 : user.did; 110 - goto(`/${handle}/e/${rkey}`); 345 + goto(`/${handle}/events/${rkey}`); 111 346 } else { 112 347 error = 'Failed to create event. Please try again.'; 113 348 } ··· 124 359 <title>Create Event</title> 125 360 </svelte:head> 126 361 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 - 362 + <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12 sm:py-12"> 363 + <div class="mx-auto max-w-4xl"> 131 364 {#if user.isInitializing} 132 365 <div class="flex items-center gap-3"> 133 366 <div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div> ··· 141 374 <Button onclick={() => loginModalState.show()}>Log in</Button> 142 375 </div> 143 376 {:else} 377 + <div class="mb-6 flex items-center gap-3"> 378 + <Badge size="sm">Local draft</Badge> 379 + {#if hasDraft} 380 + <button 381 + type="button" 382 + onclick={deleteDraft} 383 + class="text-base-500 dark:text-base-400 cursor-pointer text-xs hover:text-red-500 hover:underline" 384 + > 385 + Delete draft 386 + </button> 387 + {/if} 388 + </div> 389 + 144 390 <form 145 391 onsubmit={(e) => { 146 392 e.preventDefault(); 147 393 handleSubmit(); 148 394 }} 149 - class="space-y-6" 150 395 > 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" 396 + <!-- Two-column layout mirroring detail page --> 397 + <div 398 + class="grid grid-cols-1 gap-8 md:grid-cols-[14rem_1fr] md:gap-x-10 md:gap-y-6 lg:grid-cols-[16rem_1fr]" 399 + > 400 + <!-- Thumbnail (left column) --> 401 + <!-- svelte-ignore a11y_no_static_element_interactions --> 402 + <div 403 + class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none" 404 + ondragover={onDragOver} 405 + ondragleave={onDragLeave} 406 + ondrop={onDrop} 156 407 > 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 - /> 408 + <input 409 + bind:this={fileInput} 410 + type="file" 411 + accept="image/*" 412 + onchange={onFileChange} 413 + class="hidden" 414 + /> 415 + {#if thumbnailPreview} 416 + <div class="relative"> 417 + <button type="button" onclick={() => fileInput?.click()} class="w-full"> 418 + <img 419 + src={thumbnailPreview} 420 + alt="Thumbnail preview" 421 + class="border-base-200 dark:border-base-800 aspect-square w-full cursor-pointer rounded-2xl border object-cover" 422 + /> 423 + </button> 424 + <button 425 + type="button" 426 + onclick={removeThumbnail} 427 + aria-label="Remove thumbnail" 428 + class="bg-base-900/70 absolute top-2 right-2 flex size-7 items-center justify-center rounded-full text-white hover:bg-red-600" 429 + > 430 + <svg 431 + xmlns="http://www.w3.org/2000/svg" 432 + viewBox="0 0 20 20" 433 + fill="currentColor" 434 + class="size-4" 435 + > 436 + <path 437 + 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" 438 + /> 439 + </svg> 440 + </button> 441 + </div> 442 + {:else} 174 443 <button 175 444 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" 445 + onclick={() => fileInput?.click()} 446 + 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 aspect-square w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed transition-colors {isDragOver 447 + ? 'border-accent-500 bg-accent-50 dark:bg-accent-950 text-accent-500' 448 + : ''}" 179 449 > 180 450 <svg 181 451 xmlns="http://www.w3.org/2000/svg" 182 - viewBox="0 0 20 20" 183 - fill="currentColor" 184 - class="size-3.5" 452 + fill="none" 453 + viewBox="0 0 24 24" 454 + stroke-width="1.5" 455 + stroke="currentColor" 456 + class="mb-1 size-6" 185 457 > 186 458 <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" 459 + stroke-linecap="round" 460 + stroke-linejoin="round" 461 + 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" 188 462 /> 189 463 </svg> 464 + <span class="text-sm">Add image</span> 190 465 </button> 466 + {/if} 467 + </div> 468 + 469 + <!-- Right column: event details --> 470 + <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> 471 + <!-- Name --> 472 + <input 473 + type="text" 474 + bind:value={name} 475 + required 476 + placeholder="Event name" 477 + class="text-base-900 dark:text-base-50 placeholder:text-base-300 dark:placeholder:text-base-700 mb-2 w-full border-0 bg-transparent text-4xl leading-tight font-bold focus:border-0 focus:ring-0 focus:outline-none sm:text-5xl" 478 + /> 479 + 480 + <!-- Mode toggle --> 481 + <div class="mb-8"> 482 + <ToggleGroup 483 + type="single" 484 + bind:value={ 485 + () => { 486 + return mode; 487 + }, 488 + (val) => { 489 + if (val) mode = val; 490 + } 491 + } 492 + class="w-fit" 493 + > 494 + <ToggleGroupItem size="sm" value="inperson">In Person</ToggleGroupItem> 495 + <ToggleGroupItem size="sm" value="virtual">Virtual</ToggleGroupItem> 496 + <ToggleGroupItem size="sm" value="hybrid">Hybrid</ToggleGroupItem> 497 + </ToggleGroup> 191 498 </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" 499 + 500 + <!-- Date row --> 501 + <div class="mb-4 flex items-center gap-4"> 502 + <div 503 + class="border-base-200 dark:border-base-700 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 205 504 > 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> 505 + {#if startDate} 506 + <span 507 + class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold" 508 + > 509 + {formatMonth(startDate)} 510 + </span> 511 + <span class="text-base-900 dark:text-base-50 text-lg leading-tight font-bold"> 512 + {formatDay(startDate)} 513 + </span> 514 + {:else} 515 + <svg 516 + xmlns="http://www.w3.org/2000/svg" 517 + fill="none" 518 + viewBox="0 0 24 24" 519 + stroke-width="1.5" 520 + stroke="currentColor" 521 + class="text-base-400 dark:text-base-500 size-5" 522 + > 523 + <path 524 + stroke-linecap="round" 525 + stroke-linejoin="round" 526 + d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" 527 + /> 528 + </svg> 529 + {/if} 530 + </div> 531 + <div class="flex-1"> 532 + {#if startDate} 533 + <p class="text-base-900 dark:text-base-50 font-semibold"> 534 + {formatWeekday(startDate)}, {formatFullDate(startDate)} 535 + {#if endDate && !isSameDay} 536 + - {formatWeekday(endDate)}, {formatFullDate(endDate)} 537 + {/if} 538 + </p> 539 + <p class="text-base-500 dark:text-base-400 text-sm"> 540 + {formatTime(startDate)} 541 + {#if endDate && isSameDay} 542 + - {formatTime(endDate)} 543 + {/if} 544 + </p> 545 + {/if} 546 + <div class="mt-1 flex flex-wrap gap-3"> 547 + <label class="flex items-center gap-1.5"> 548 + <span class="text-base-500 dark:text-base-400 text-xs">Start</span> 549 + <input 550 + type="datetime-local" 551 + bind:value={startsAt} 552 + required 553 + class="text-base-700 dark:text-base-300 bg-transparent text-xs focus:outline-none" 554 + /> 555 + </label> 556 + <label class="flex items-center gap-1.5"> 557 + <span class="text-base-500 dark:text-base-400 text-xs">End</span> 558 + <input 559 + type="datetime-local" 560 + bind:value={endsAt} 561 + class="text-base-700 dark:text-base-300 bg-transparent text-xs focus:outline-none" 562 + /> 563 + </label> 564 + </div> 565 + </div> 566 + </div> 567 + 568 + <!-- About Event --> 569 + <div class="mt-8 mb-8"> 570 + <p 571 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 572 + > 573 + About 574 + </p> 575 + <textarea 576 + bind:value={description} 577 + rows={4} 578 + placeholder="What's this event about? @mentions, #hashtags and links will be detected automatically." 579 + class="text-base-700 dark:text-base-300 placeholder:text-base-300 dark:placeholder:text-base-700 w-full resize-none border-0 bg-transparent leading-relaxed focus:border-0 focus:ring-0 focus:outline-none" 580 + ></textarea> 581 + </div> 216 582 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> 583 + {#if error} 584 + <p class="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p> 585 + {/if} 234 586 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> 587 + <Button type="submit" disabled={submitting}> 588 + {submitting ? 'Creating...' : 'Create Event'} 589 + </Button> 590 + </div> 251 591 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> 592 + <!-- Hosted By --> 593 + <div class="order-3 md:order-0 md:col-start-1"> 594 + <p 595 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 596 + > 597 + Hosted By 598 + </p> 599 + <div class="flex items-center gap-2.5"> 600 + <FoxAvatar src={user.profile?.avatar} alt={hostName} class="size-8 shrink-0" /> 601 + <span class="text-base-900 dark:text-base-100 truncate text-sm font-medium"> 602 + {hostName} 603 + </span> 604 + </div> 605 + </div> 268 606 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> 607 + <!-- Links --> 608 + <div class="order-4 md:order-0 md:col-start-1"> 609 + <p 610 + class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase" 611 + > 612 + Links 613 + </p> 614 + <div class="space-y-3"> 615 + {#each links as link, i (i)} 616 + <div class="group flex items-center gap-1.5"> 617 + <svg 618 + xmlns="http://www.w3.org/2000/svg" 619 + fill="none" 620 + viewBox="0 0 24 24" 621 + stroke-width="1.5" 622 + stroke="currentColor" 623 + class="text-base-700 dark:text-base-300 size-3.5 shrink-0" 624 + > 625 + <path 626 + stroke-linecap="round" 627 + stroke-linejoin="round" 628 + d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 629 + /> 630 + </svg> 631 + <span class="text-base-700 dark:text-base-300 truncate text-sm"> 632 + {link.name || link.uri.replace(/^https?:\/\//, '')} 633 + </span> 634 + <button 635 + type="button" 636 + onclick={() => removeLink(i)} 637 + class="text-base-400 ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-500" 638 + aria-label="Remove link" 639 + > 640 + <svg 641 + xmlns="http://www.w3.org/2000/svg" 642 + viewBox="0 0 20 20" 643 + fill="currentColor" 644 + class="size-3.5" 645 + > 646 + <path 647 + 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" 648 + /> 649 + </svg> 650 + </button> 651 + </div> 652 + {/each} 653 + </div> 284 654 285 - {#if error} 286 - <p class="text-sm text-red-600 dark:text-red-400">{error}</p> 287 - {/if} 655 + <div class="relative mt-3"> 656 + <button 657 + type="button" 658 + onclick={() => (showLinkPopup = !showLinkPopup)} 659 + class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 flex items-center gap-1.5 text-sm transition-colors" 660 + > 661 + <svg 662 + xmlns="http://www.w3.org/2000/svg" 663 + fill="none" 664 + viewBox="0 0 24 24" 665 + stroke-width="1.5" 666 + stroke="currentColor" 667 + class="size-4" 668 + > 669 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 670 + </svg> 671 + Add link 672 + </button> 288 673 289 - <Button type="submit" disabled={submitting} class="w-full"> 290 - {submitting ? 'Creating...' : 'Create Event'} 291 - </Button> 674 + {#if showLinkPopup} 675 + <div 676 + class="border-base-200 dark:border-base-700 bg-base-50 dark:bg-base-900 absolute top-full left-0 z-10 mt-2 w-64 rounded-xl border p-3 shadow-lg" 677 + > 678 + <input 679 + type="url" 680 + bind:value={newLinkUri} 681 + placeholder="https://..." 682 + class="border-base-300 dark:border-base-700 bg-base-100 dark:bg-base-800 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 mb-2 w-full rounded-lg border px-2.5 py-1.5 text-sm focus:outline-none" 683 + /> 684 + <input 685 + type="text" 686 + bind:value={newLinkName} 687 + placeholder="Label (optional)" 688 + class="border-base-300 dark:border-base-700 bg-base-100 dark:bg-base-800 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 mb-3 w-full rounded-lg border px-2.5 py-1.5 text-sm focus:outline-none" 689 + /> 690 + <div class="flex justify-end gap-2"> 691 + <button 692 + type="button" 693 + onclick={() => (showLinkPopup = false)} 694 + class="text-base-500 dark:text-base-400 text-xs hover:underline" 695 + > 696 + Cancel 697 + </button> 698 + <button 699 + type="button" 700 + onclick={addLink} 701 + disabled={!newLinkUri.trim()} 702 + class="bg-base-900 dark:bg-base-100 text-base-50 dark:text-base-900 disabled:bg-base-300 dark:disabled:bg-base-700 rounded-lg px-3 py-1 text-xs font-medium disabled:cursor-not-allowed" 703 + > 704 + Add 705 + </button> 706 + </div> 707 + </div> 708 + {/if} 709 + </div> 710 + </div> 711 + </div> 292 712 </form> 293 713 {/if} 294 714 </div>