atmo.rsvp
5
fork

Configure Feed

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

allow embedding create

Florian 6408a770 c80bce9e

+316 -74
-9
src/app.d.ts
··· 50 50 notify(name: string, payload?: unknown): void; 51 51 } 52 52 53 - type BlentoErrorCode = 54 - | 'no_session' 55 - | 'user_cancelled' 56 - | 'rate_limited' 57 - | 'pds_error' 58 - | 'unsupported' 59 - | 'invalid_request' 60 - | 'unknown'; 61 - 62 53 declare global { 63 54 interface Window { 64 55 Blento?: Blento;
+37 -40
src/lib/components/EventEditor.svelte
··· 1 1 <script lang="ts"> 2 - import { user } from '$lib/atproto/auth.svelte'; 3 - import { atProtoLoginModalState } from '$lib/components/LoginModal.svelte'; 4 - import { putRecord, deleteRecord } from '$lib/atproto/methods'; 5 2 import { getCDNImageBlobUrl } from '$lib/atproto'; 6 3 import { notifyContrailOfUpdate } from '$lib/contrail'; 7 4 import { ··· 10 7 ToggleGroup, 11 8 ToggleGroupItem 12 9 } from '@foxui/core'; 13 - import { goto } from '$app/navigation'; 14 10 import { onMount } from 'svelte'; 15 11 import { dev } from '$app/environment'; 16 12 import { PlainTextEditor } from '@foxui/text'; ··· 40 36 } from './editor/types'; 41 37 import { buildEventRecord, buildThumbnailMedia, renderPresetThumbnail } from './editor/save'; 42 38 import { DEFAULT_PRESET, hashSeed } from './thumbnails/designs'; 39 + import type { EditorAdapter, EditorViewer } from './editor/adapter'; 43 40 44 41 let { 45 42 eventData = null, 46 43 actorDid, 47 44 rkey, 48 - privateMode = false 45 + privateMode = false, 46 + adapter, 47 + viewer 49 48 }: { 50 49 eventData: FlatEventRecord | null; 51 50 actorDid: string; 52 51 rkey: string; 53 52 /** If true, save writes into a permissioned space instead of the user's public PDS. */ 54 53 privateMode?: boolean; 54 + adapter: EditorAdapter; 55 + viewer: EditorViewer; 55 56 } = $props(); 56 57 57 58 let isNew = $derived(eventData === null); ··· 148 149 if (titleEditor) get(titleEditor)?.commands.focus(); 149 150 }); 150 151 151 - let hostName = $derived(user.profile?.displayName || user.profile?.handle || user.did || ''); 152 + let hostName = $derived(viewer.displayName || viewer.handle || viewer.did || ''); 152 153 153 154 let thumbnailDateStr = $derived.by(() => { 154 155 if (!startsAt) return ''; ··· 187 188 if (!name.trim()) return void (error = 'Name is required.'); 188 189 if (!startsAt) return void (error = 'Start date is required.'); 189 190 if (!endsAt) return void (error = 'End date is required.'); 190 - if (!user.isLoggedIn || !user.did) return void (error = 'You must be logged in.'); 191 + if (!viewer.isLoggedIn || !viewer.did) return void (error = 'You must be logged in.'); 191 192 192 193 submitting = true; 193 194 ··· 212 213 isNew, 213 214 thumbnailChanged, 214 215 thumbnailFile, 215 - existingMedia 216 + existingMedia, 217 + uploadBlob: (blob) => 218 + adapter.uploadBlob(blob) as unknown as Promise<Record<string, unknown>> 216 219 }); 217 220 218 221 const record = await buildEventRecord({ ··· 233 236 }); 234 237 235 238 if (visibility === 'private') { 236 - const { createPrivateEvent } = await import('$lib/spaces/server/spaces.remote'); 237 - const { spaceUri, rkey: eventRkey } = await createPrivateEvent({ key: rkey, record }); 238 - const spaceKey = spaceUri.split('/').pop(); 239 + if (!adapter.createPrivateEvent) { 240 + error = 'Private events are not supported here.'; 241 + return; 242 + } 243 + const { rkey: eventRkey, spaceKey } = await adapter.createPrivateEvent({ 244 + key: rkey, 245 + record 246 + }); 239 247 const handle = 240 - user.profile?.handle && user.profile.handle !== 'handle.invalid' 241 - ? user.profile.handle 242 - : user.did; 243 - goto(`/p/${handle}/e/${eventRkey}/s/${spaceKey}?created=true`); 248 + viewer.handle && viewer.handle !== 'handle.invalid' ? viewer.handle : viewer.did; 249 + const target = `/p/${handle}/e/${eventRkey}/s/${spaceKey}?created=true`; 250 + const { goto } = await import('$app/navigation'); 251 + goto(target); 244 252 return; 245 253 } 246 254 247 - const response = await putRecord({ 255 + const result = await adapter.putRecord({ 248 256 collection: 'community.lexicon.calendar.event', 249 257 rkey, 250 258 record 251 259 }); 252 260 253 - if (response.ok) { 254 - const eventUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`; 255 - await notifyContrailOfUpdate(eventUri); 256 - const handle = 257 - user.profile?.handle && user.profile.handle !== 'handle.invalid' 258 - ? user.profile.handle 259 - : user.did; 260 - goto(`/p/${handle}/e/${rkey}${isNew ? '?created=true' : ''}`); 261 - } else { 262 - error = `Failed to ${isNew ? 'create' : 'save'} event. Please try again.`; 263 - } 261 + await notifyContrailOfUpdate(result.uri); 262 + adapter.onSaved({ uri: result.uri, rkey, isNew }); 264 263 } catch (e) { 265 264 console.error(`Failed to ${isNew ? 'create' : 'save'} event:`, e); 266 265 error = `Failed to ${isNew ? 'create' : 'save'} event. Please try again.`; ··· 275 274 async function handleDelete() { 276 275 deleting = true; 277 276 try { 278 - await deleteRecord({ 277 + await adapter.deleteRecord({ 279 278 collection: 'community.lexicon.calendar.event', 280 279 rkey 281 280 }); 282 - const eventUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`; 281 + const eventUri = `at://${viewer.did}/community.lexicon.calendar.event/${rkey}`; 283 282 await notifyContrailOfUpdate(eventUri); 284 - const handle = 285 - user.profile?.handle && user.profile.handle !== 'handle.invalid' 286 - ? user.profile.handle 287 - : user.did; 288 - goto(`/p/${handle}`); 283 + adapter.onDeleted?.(); 289 284 } catch (e) { 290 285 console.error('Failed to delete event:', e); 291 286 error = 'Failed to delete event. Please try again.'; ··· 301 296 302 297 <div class="px-6 py-12 sm:py-12"> 303 298 <div class="mx-auto max-w-3xl"> 304 - {#if !user.isLoggedIn} 299 + {#if !viewer.isLoggedIn} 305 300 <div 306 301 class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 rounded-2xl border p-8 text-center" 307 302 > 308 303 <p class="text-base-600 dark:text-base-400 mb-4"> 309 304 Log in to {isNew ? 'create an event' : 'edit this event'}. 310 305 </p> 311 - <Button onclick={() => atProtoLoginModalState.show()}>Log in</Button> 306 + <Button onclick={() => adapter.requestLogin()}>Log in</Button> 312 307 </div> 313 308 {:else} 314 309 <form ··· 390 385 disabled={!isNew && visibility === 'private'} 391 386 > 392 387 <ToggleGroupItem value="public">Public</ToggleGroupItem> 393 - {#if dev} 388 + {#if dev && adapter.features.privateMode} 394 389 <ToggleGroupItem value="private">Private</ToggleGroupItem> 395 390 {/if} 396 391 <ToggleGroupItem value="unlisted">Unlisted</ToggleGroupItem> ··· 454 449 ? 'Publish Event' 455 450 : 'Save Changes'} 456 451 </Button> 457 - {#if !isNew} 452 + {#if !isNew && adapter.features.recurring} 458 453 <Button 459 454 type="button" 460 455 variant="secondary" ··· 474 469 Hosted By 475 470 </p> 476 471 <div class="flex items-center gap-2.5"> 477 - <FoxAvatar src={user.profile?.avatar} alt={hostName} class="size-8 shrink-0" /> 472 + <FoxAvatar src={viewer.avatar} alt={hostName} class="size-8 shrink-0" /> 478 473 <span class="text-base-900 dark:text-base-100 truncate text-sm font-medium"> 479 474 {hostName} 480 475 </span> ··· 487 482 </div> 488 483 </div> 489 484 490 - {#if !isNew} 485 + {#if !isNew && adapter.features.delete} 491 486 <div class="border-base-200 dark:border-base-800 mt-12 border-t pt-8"> 492 487 {#if showDeleteConfirm} 493 488 <div class="flex items-center gap-3"> ··· 533 528 {thumbnailFile} 534 529 {thumbnailChanged} 535 530 {selectedPreset} 531 + {adapter} 532 + {viewer} 536 533 accent={eventTheme.accentColor} 537 534 />
+19 -16
src/lib/components/editor/RecurringModal.svelte
··· 2 2 import { Button, Checkbox, Input, Modal, ToggleGroup, ToggleGroupItem } from '@foxui/core'; 3 3 import { parseDateTime } from '@internationalized/date'; 4 4 import * as TID from '@atcute/tid'; 5 - import { putRecord } from '$lib/atproto/methods'; 6 5 import { notifyContrailOfUpdate } from '$lib/contrail'; 7 - import { user } from '$lib/atproto/auth.svelte'; 8 6 import type { FlatEventRecord } from '$lib/contrail'; 9 7 import type { EventLocation, EventMode } from './types'; 10 8 import { buildThumbnailMedia, renderPresetThumbnail } from './save'; 11 9 import { hashSeed } from '$lib/components/thumbnails/designs'; 10 + import type { EditorAdapter, EditorViewer } from './adapter'; 12 11 13 12 let { 14 13 open = $bindable(), ··· 27 26 thumbnailFile, 28 27 thumbnailChanged, 29 28 selectedPreset, 30 - accent 29 + accent, 30 + adapter, 31 + viewer 31 32 }: { 32 33 open: boolean; 33 34 rkey: string; ··· 46 47 thumbnailChanged: boolean; 47 48 selectedPreset: string | null; 48 49 accent: string; 50 + adapter: EditorAdapter; 51 + viewer: EditorViewer; 49 52 } = $props(); 50 53 51 54 let interval = $state(1); ··· 64 67 }); 65 68 66 69 async function handleCreate() { 67 - if (!name.trim() || !startsAt || !user.isLoggedIn || !user.did) return; 70 + if (!name.trim() || !startsAt || !viewer.isLoggedIn || !viewer.did) return; 68 71 69 72 creating = true; 70 73 errorMsg = null; ··· 108 111 isNew, 109 112 thumbnailChanged: hasNewThumbnail, 110 113 thumbnailFile: fileForUpload, 111 - existingMedia 114 + existingMedia, 115 + uploadBlob: (blob) => 116 + adapter.uploadBlob(blob) as unknown as Promise<Record<string, unknown>> 112 117 }); 113 118 114 - const parentUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`; 119 + const parentUri = `at://${viewer.did}/community.lexicon.calendar.event/${rkey}`; 115 120 116 121 for (let i = 0; i < count; i++) { 117 122 const offset = i + 1; ··· 165 170 ]; 166 171 } 167 172 168 - const response = await putRecord({ 169 - collection: 'community.lexicon.calendar.event', 170 - rkey: newRkey, 171 - record 172 - }); 173 - 174 - if (response.ok) { 175 - const eventUri = `at://${user.did}/community.lexicon.calendar.event/${newRkey}`; 176 - await notifyContrailOfUpdate(eventUri); 173 + try { 174 + const result = await adapter.putRecord({ 175 + collection: 'community.lexicon.calendar.event', 176 + rkey: newRkey, 177 + record 178 + }); 179 + await notifyContrailOfUpdate(result.uri); 177 180 created = i + 1; 178 - } else { 181 + } catch { 179 182 errorMsg = `Failed to create event ${i + 1}. Stopping.`; 180 183 return; 181 184 }
+134
src/lib/components/editor/adapter.ts
··· 1 + import { goto } from '$app/navigation'; 2 + import { putRecord, deleteRecord, uploadBlob } from '$lib/atproto/methods'; 3 + import { atProtoLoginModalState } from '$lib/components/LoginModal.svelte'; 4 + 5 + export type EditorBlobRef = { 6 + $type: 'blob'; 7 + ref: { $link: string }; 8 + mimeType: string; 9 + size: number; 10 + }; 11 + 12 + export type EditorViewer = { 13 + isLoggedIn: boolean; 14 + did: string | null; 15 + handle?: string; 16 + displayName?: string; 17 + avatar?: string; 18 + }; 19 + 20 + export type EditorAdapter = { 21 + features: { 22 + delete: boolean; 23 + recurring: boolean; 24 + privateMode: boolean; 25 + }; 26 + putRecord(opts: { 27 + collection: string; 28 + rkey: string; 29 + record: Record<string, unknown>; 30 + }): Promise<{ uri: string }>; 31 + deleteRecord(opts: { collection: string; rkey: string }): Promise<void>; 32 + uploadBlob(blob: Blob): Promise<EditorBlobRef>; 33 + onSaved(result: { uri: string; rkey: string; isNew: boolean }): void; 34 + onDeleted?(): void; 35 + requestLogin(): void; 36 + createPrivateEvent?(opts: { 37 + key: string; 38 + record: Record<string, unknown>; 39 + }): Promise<{ spaceUri: string; rkey: string; spaceKey: string }>; 40 + }; 41 + 42 + function handleOrDid(viewer: EditorViewer): string { 43 + if (viewer.handle && viewer.handle !== 'handle.invalid') return viewer.handle; 44 + return viewer.did ?? ''; 45 + } 46 + 47 + export function createInAppAdapter(opts: { viewer: EditorViewer }): EditorAdapter { 48 + const { viewer } = opts; 49 + return { 50 + features: { delete: true, recurring: true, privateMode: true }, 51 + async putRecord({ collection, rkey, record }) { 52 + const response = await putRecord({ 53 + collection: collection as Parameters<typeof putRecord>[0]['collection'], 54 + rkey, 55 + record 56 + }); 57 + if (!response.ok) throw new Error('putRecord failed'); 58 + if (!viewer.did) throw new Error('Not logged in'); 59 + return { uri: `at://${viewer.did}/${collection}/${rkey}` }; 60 + }, 61 + async deleteRecord({ collection, rkey }) { 62 + await deleteRecord({ 63 + collection: collection as Parameters<typeof deleteRecord>[0]['collection'], 64 + rkey 65 + }); 66 + }, 67 + async uploadBlob(blob) { 68 + const result = await uploadBlob({ blob }); 69 + if (!result) throw new Error('uploadBlob failed'); 70 + const { aspectRatio: _ar, ...rest } = result as Record<string, unknown> & { 71 + aspectRatio?: unknown; 72 + }; 73 + return rest as unknown as EditorBlobRef; 74 + }, 75 + onSaved({ rkey, isNew }) { 76 + goto(`/p/${handleOrDid(viewer)}/e/${rkey}${isNew ? '?created=true' : ''}`); 77 + }, 78 + onDeleted() { 79 + goto(`/p/${handleOrDid(viewer)}`); 80 + }, 81 + requestLogin() { 82 + atProtoLoginModalState.show(); 83 + }, 84 + async createPrivateEvent({ key, record }) { 85 + const { createPrivateEvent } = await import('$lib/spaces/server/spaces.remote'); 86 + const result = await createPrivateEvent({ key, record }); 87 + const spaceKey = result.spaceUri.split('/').pop() ?? ''; 88 + return { spaceUri: result.spaceUri, rkey: result.rkey, spaceKey }; 89 + } 90 + }; 91 + } 92 + 93 + export function createBlentoAdapter(opts: { 94 + viewer: EditorViewer; 95 + onAfterSave?: (result: { uri: string; rkey: string; isNew: boolean }) => void; 96 + }): EditorAdapter { 97 + const { viewer, onAfterSave } = opts; 98 + function blento(): NonNullable<typeof window.Blento> { 99 + const b = typeof window !== 'undefined' ? window.Blento : undefined; 100 + if (!b) throw new Error('Blento SDK not available'); 101 + return b; 102 + } 103 + return { 104 + features: { delete: false, recurring: true, privateMode: false }, 105 + async putRecord({ collection, rkey, record }) { 106 + const result = await blento().putRecord({ collection, rkey, record }); 107 + return { uri: result.uri }; 108 + }, 109 + async deleteRecord({ collection, rkey }) { 110 + await blento().deleteRecord({ collection, rkey }); 111 + }, 112 + async uploadBlob(blob) { 113 + const ref = await blento().uploadBlob(blob); 114 + return ref as EditorBlobRef; 115 + }, 116 + onSaved(result) { 117 + try { 118 + blento().notify('event-created', result); 119 + } catch { 120 + // Blento not present (e.g. preview); swallow. 121 + } 122 + onAfterSave?.(result); 123 + }, 124 + requestLogin() { 125 + try { 126 + blento().promptLogin(); 127 + } catch { 128 + // Blento not present; swallow. 129 + } 130 + }, 131 + // no createPrivateEvent — privateMode disabled in features 132 + // no onDeleted — delete is disabled in features 133 + } as EditorAdapter; 134 + }
+5 -7
src/lib/components/editor/save.ts
··· 1 - import { uploadBlob, resolveHandle } from '$lib/atproto/methods'; 1 + import { resolveHandle } from '$lib/atproto/methods'; 2 2 import { compressImage } from '$lib/atproto/image-helper'; 3 3 import { tokenize, type Token } from '@atcute/bluesky-richtext-parser'; 4 4 import type { Handle } from '@atcute/lexicons'; ··· 84 84 thumbnailChanged: boolean; 85 85 thumbnailFile: File | null; 86 86 existingMedia: Array<Record<string, unknown>>; 87 + uploadBlob: (blob: Blob) => Promise<Record<string, unknown>>; 87 88 }): Promise<Array<Record<string, unknown>> | undefined> { 88 - const { isNew, thumbnailChanged, thumbnailFile, existingMedia } = args; 89 + const { isNew, thumbnailChanged, thumbnailFile, existingMedia, uploadBlob } = args; 89 90 90 91 if (!isNew && !thumbnailChanged) { 91 92 return existingMedia.length > 0 ? existingMedia : undefined; ··· 97 98 } 98 99 99 100 const compressed = await compressImage(thumbnailFile); 100 - const result = await uploadBlob({ blob: compressed.blob }); 101 - if (!result) return existingMedia.length > 0 ? existingMedia : undefined; 101 + const blobRef = await uploadBlob(compressed.blob); 102 + if (!blobRef) return existingMedia.length > 0 ? existingMedia : undefined; 102 103 103 - const { aspectRatio: _ar, ...blobRef } = result as Record<string, unknown> & { 104 - aspectRatio?: unknown; 105 - }; 106 104 return [ 107 105 ...existingMedia.filter((m) => m.role !== 'thumbnail'), 108 106 {
+19 -1
src/routes/(app)/create/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import EventEditor from '$lib/components/EventEditor.svelte'; 3 3 import { page } from '$app/state'; 4 + import { user } from '$lib/atproto/auth.svelte'; 5 + import { createInAppAdapter } from '$lib/components/editor/adapter'; 4 6 5 7 let { data } = $props(); 6 8 let privateMode = $derived(page.url.searchParams.get('private') === '1'); 9 + 10 + let viewer = $derived({ 11 + isLoggedIn: user.isLoggedIn, 12 + did: user.did ?? null, 13 + handle: user.profile?.handle, 14 + displayName: user.profile?.displayName, 15 + avatar: user.profile?.avatar 16 + }); 17 + let adapter = $derived(createInAppAdapter({ viewer })); 7 18 </script> 8 19 9 20 <svelte:head> 10 21 <title>Create Event</title> 11 22 </svelte:head> 12 23 13 - <EventEditor eventData={null} actorDid={data.actorDid} rkey={data.rkey} {privateMode} /> 24 + <EventEditor 25 + eventData={null} 26 + actorDid={data.actorDid} 27 + rkey={data.rkey} 28 + {privateMode} 29 + {adapter} 30 + {viewer} 31 + />
+18 -1
src/routes/(app)/p/[actor]/e/[rkey]/edit/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import EventEditor from '$lib/components/EventEditor.svelte'; 3 + import { user } from '$lib/atproto/auth.svelte'; 4 + import { createInAppAdapter } from '$lib/components/editor/adapter'; 3 5 4 6 let { data } = $props(); 7 + 8 + let viewer = $derived({ 9 + isLoggedIn: user.isLoggedIn, 10 + did: user.did ?? null, 11 + handle: user.profile?.handle, 12 + displayName: user.profile?.displayName, 13 + avatar: user.profile?.avatar 14 + }); 15 + let adapter = $derived(createInAppAdapter({ viewer })); 5 16 </script> 6 17 7 18 <svelte:head> 8 19 <title>Edit Event</title> 9 20 </svelte:head> 10 21 11 - <EventEditor eventData={data.eventData} actorDid={data.actorDid} rkey={data.rkey} /> 22 + <EventEditor 23 + eventData={data.eventData} 24 + actorDid={data.actorDid} 25 + rkey={data.rkey} 26 + {adapter} 27 + {viewer} 28 + />
+5
src/routes/embed/create/+page.server.ts
··· 1 + import { now as tidNow } from '@atcute/tid'; 2 + 3 + export function load() { 4 + return { rkey: tidNow() }; 5 + }
+79
src/routes/embed/create/+page.svelte
··· 1 + <script lang="ts"> 2 + import EventEditor from '$lib/components/EventEditor.svelte'; 3 + import { onMount } from 'svelte'; 4 + import { createBlentoAdapter, type EditorViewer } from '$lib/components/editor/adapter'; 5 + 6 + let { data } = $props(); 7 + 8 + type Session = { 9 + did: string; 10 + handle?: string; 11 + displayName?: string; 12 + avatar?: string; 13 + }; 14 + 15 + let ready = $state(false); 16 + let session = $state<Session | null>(null); 17 + 18 + let viewer = $derived<EditorViewer>({ 19 + isLoggedIn: !!session, 20 + did: session?.did ?? null, 21 + handle: session?.handle, 22 + displayName: session?.displayName, 23 + avatar: session?.avatar 24 + }); 25 + let adapter = $derived(createBlentoAdapter({ viewer })); 26 + 27 + onMount(() => { 28 + if (!window.Blento) return; 29 + let unsubscribe: (() => void) | undefined; 30 + let cancelled = false; 31 + (async () => { 32 + try { 33 + await window.Blento!.ready; 34 + } catch { 35 + return; 36 + } 37 + if (cancelled) return; 38 + session = window.Blento!.getSession(); 39 + ready = true; 40 + unsubscribe = window.Blento!.on('session', (s) => { 41 + session = s; 42 + }); 43 + })(); 44 + return () => { 45 + cancelled = true; 46 + unsubscribe?.(); 47 + }; 48 + }); 49 + </script> 50 + 51 + <svelte:head> 52 + <!-- Apply theme classes before paint to avoid flash --> 53 + <script> 54 + var p = new URLSearchParams(location.search); 55 + var h = document.documentElement; 56 + var b = p.get('base'); 57 + if (b) h.classList.add(b); 58 + var a = p.get('accent'); 59 + if (a) h.classList.add(a); 60 + if (p.get('dark') === '1') h.classList.add('dark'); 61 + </script> 62 + <script src="https://blento.app/embed/v0/sdk.js"></script> 63 + </svelte:head> 64 + 65 + {#if !ready} 66 + <div class="bg-base-50 dark:bg-base-950 flex h-full items-center justify-center"> 67 + <div 68 + class="border-base-300 dark:border-base-700 border-t-accent-600 size-6 animate-spin rounded-full border-2" 69 + ></div> 70 + </div> 71 + {:else} 72 + <EventEditor 73 + eventData={null} 74 + actorDid={viewer.did ?? ''} 75 + rkey={data.rkey} 76 + {adapter} 77 + {viewer} 78 + /> 79 + {/if}