atmo.rsvp
5
fork

Configure Feed

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

add embeds for stuff

Florian 478afe11 6408a770

+660 -142
+10 -2
src/lib/components/EventEditor.svelte
··· 44 44 rkey, 45 45 privateMode = false, 46 46 adapter, 47 - viewer 47 + viewer, 48 + initialTheme 48 49 }: { 49 50 eventData: FlatEventRecord | null; 50 51 actorDid: string; ··· 53 54 privateMode?: boolean; 54 55 adapter: EditorAdapter; 55 56 viewer: EditorViewer; 57 + /** Override default theme for new events (e.g. inherit embedder's palette). */ 58 + initialTheme?: Partial<EventTheme>; 56 59 } = $props(); 57 60 58 61 let isNew = $derived(eventData === null); ··· 68 71 let mode: EventMode = $state('inperson'); 69 72 // svelte-ignore state_referenced_locally 70 73 let visibility: Visibility = $state(privateMode && dev ? 'private' : 'public'); 74 + // svelte-ignore state_referenced_locally 71 75 let eventTheme: EventTheme = $state( 72 76 eventData === null 73 - ? { ...defaultTheme, accentColor: randomAccentColor() } 77 + ? { 78 + ...defaultTheme, 79 + accentColor: initialTheme?.accentColor ?? randomAccentColor(), 80 + ...(initialTheme?.baseColor ? { baseColor: initialTheme.baseColor } : {}) 81 + } 74 82 : { ...defaultTheme } 75 83 ); 76 84 let thumbnailFile: File | null = $state(null);
+26 -21
src/lib/components/EventRsvp.svelte
··· 1 1 <script lang="ts"> 2 - import { user } from '$lib/atproto/auth.svelte'; 3 - import { putRecord, deleteRecord, createTID } from '$lib/atproto/methods'; 2 + import { createTID } from '$lib/atproto/methods'; 4 3 import { notifyContrailOfUpdate } from '$lib/contrail'; 5 - import { atProtoLoginModalState } from '$lib/components/LoginModal.svelte'; 6 4 import { Avatar, Button } from '@foxui/core'; 7 5 import { launchConfetti } from '@foxui/visual'; 6 + import type { EditorAdapter, EditorViewer } from '$lib/components/editor/adapter'; 8 7 9 8 let { 10 9 eventUri, ··· 12 11 initialRsvpStatus = null, 13 12 initialRsvpRkey = null, 14 13 spaceUri = null, 14 + adapter, 15 + viewer, 15 16 onrsvp, 16 17 oncancel, 17 18 onlogin ··· 22 23 initialRsvpRkey?: string | null; 23 24 /** If set, RSVPs write into this space instead of the user's public PDS. */ 24 25 spaceUri?: string | null; 26 + adapter: EditorAdapter; 27 + viewer: EditorViewer; 25 28 onrsvp?: (status: 'going' | 'interested', rkey: string) => void; 26 29 oncancel?: () => void; 27 30 onlogin?: () => void; ··· 37 40 let rsvpRkey = $derived(rsvpRkeyOverride !== undefined ? rsvpRkeyOverride : initialRsvpRkey); 38 41 39 42 async function submitRsvp(status: 'going' | 'interested') { 40 - if (!user.isLoggedIn || !user.did) return; 43 + if (!viewer.isLoggedIn || !viewer.did) return; 41 44 rsvpSubmitting = true; 42 45 try { 43 46 const key = rsvpRkey ?? createTID(); ··· 63 66 }); 64 67 ok = !!result; 65 68 } else { 66 - const response = await putRecord({ 67 - collection: 'community.lexicon.calendar.rsvp', 68 - rkey: key, 69 - record 70 - }); 71 - ok = response.ok; 72 - if (ok) { 73 - notifyContrailOfUpdate(`at://${user.did}/community.lexicon.calendar.rsvp/${key}`); 69 + try { 70 + await adapter.putRecord({ 71 + collection: 'community.lexicon.calendar.rsvp', 72 + rkey: key, 73 + record 74 + }); 75 + ok = true; 76 + notifyContrailOfUpdate(`at://${viewer.did}/community.lexicon.calendar.rsvp/${key}`); 77 + } catch (e) { 78 + console.error('RSVP putRecord failed:', e); 74 79 } 75 80 } 76 81 ··· 88 93 } 89 94 90 95 async function cancelRsvp() { 91 - if (!user.isLoggedIn || !user.did || !rsvpRkey) return; 96 + if (!viewer.isLoggedIn || !viewer.did || !rsvpRkey) return; 92 97 rsvpSubmitting = true; 93 98 try { 94 99 if (spaceUri) { ··· 99 104 rkey: rsvpRkey 100 105 }); 101 106 } else { 102 - await deleteRecord({ 107 + await adapter.deleteRecord({ 103 108 collection: 'community.lexicon.calendar.rsvp', 104 109 rkey: rsvpRkey 105 110 }); 106 111 notifyContrailOfUpdate( 107 - `at://${user.did}/community.lexicon.calendar.rsvp/${rsvpRkey}` 112 + `at://${viewer.did}/community.lexicon.calendar.rsvp/${rsvpRkey}` 108 113 ); 109 114 } 110 115 rsvpStatusOverride = null; ··· 121 126 <div 122 127 class="border-base-200 dark:border-base-800 bg-base-100 items-between dark:bg-base-950/50 mt-8 mb-2 flex h-25 flex-col justify-center rounded-2xl border p-4" 123 128 > 124 - {#if !user.isLoggedIn} 129 + {#if !viewer.isLoggedIn} 125 130 <div class="flex items-center justify-between gap-4"> 126 131 <p class="text-base-600 dark:text-base-400 text-sm">Log in to RSVP to this event</p> 127 132 128 - <Button onclick={() => { onlogin?.(); atProtoLoginModalState.show(); }}>Log in to RSVP</Button> 133 + <Button onclick={() => { onlogin?.(); adapter.requestLogin(); }}>Log in to RSVP</Button> 129 134 </div> 130 135 {:else if rsvpStatus === 'going'} 131 136 <div class="flex items-center justify-between"> ··· 195 200 <Button onclick={cancelRsvp} disabled={rsvpSubmitting} variant="ghost">Remove</Button> 196 201 </div> 197 202 {:else} 198 - {#if user.profile} 203 + {#if viewer.isLoggedIn} 199 204 <div class="mb-4 flex items-center gap-2"> 200 205 <span class="text-base-500 dark:text-base-400 text-sm">Attend as</span> 201 206 <Avatar 202 - src={user.profile.avatar} 203 - alt={user.profile.displayName || user.profile.handle} 207 + src={viewer.avatar} 208 + alt={viewer.displayName || viewer.handle || viewer.did || ''} 204 209 class="size-5" 205 210 /> 206 211 <span class="text-base-700 dark:text-base-300 truncate text-sm font-medium"> 207 - {user.profile.displayName || user.profile.handle} 212 + {viewer.displayName || viewer.handle || viewer.did} 208 213 </span> 209 214 </div> 210 215 {/if}
+39 -14
src/lib/components/EventView.svelte
··· 1 1 <script lang="ts"> 2 2 import { eventUrl, isEventOngoing, type FlatEventRecord } from '$lib/contrail'; 3 3 import { getCDNImageBlobUrl } from '$lib/atproto'; 4 - import { user } from '$lib/atproto/auth.svelte'; 5 4 import { Avatar as FoxAvatar, Button, ToggleGroup, ToggleGroupItem } from '@foxui/core'; 6 5 import ShareModal from '$lib/components/ShareModal.svelte'; 7 6 import EventComments from '$lib/components/EventComments.svelte'; ··· 17 16 import ThemeApply from '$lib/components/ThemeApply.svelte'; 18 17 import { defaultTheme, type EventTheme } from '$lib/theme'; 19 18 import { onMount } from 'svelte'; 19 + import type { EditorAdapter, EditorViewer } from '$lib/components/editor/adapter'; 20 20 21 21 import EventBadges from './event-view/EventBadges.svelte'; 22 22 import EventDateBlock from './event-view/EventDateBlock.svelte'; ··· 28 28 import InviteShareFlow from './event-view/InviteShareFlow.svelte'; 29 29 import { buildDescriptionHtml, getLocationData, resolveGeoLocation, type GeoLocation } from './event-view/format'; 30 30 31 - let { data } = $props(); 31 + let { 32 + data, 33 + adapter, 34 + viewer, 35 + embedMode = false, 36 + shareUrlOverride 37 + }: { 38 + data: any; 39 + adapter: EditorAdapter; 40 + viewer: EditorViewer; 41 + embedMode?: boolean; 42 + /** When set, the share modal / Bluesky post embed use this URL instead 43 + * of the canonical atmo.rsvp event URL. Useful for embedders that want 44 + * share links to point at their own event page. */ 45 + shareUrlOverride?: string; 46 + } = $props(); 32 47 33 48 let eventData: FlatEventRecord = $derived(data.eventData); 34 49 let did: string = $derived(data.actorDid); ··· 41 56 let hostUrl = $derived(`/p/${hostProfile?.handle || did}`); 42 57 let eventPath = $derived(eventUrl(eventData, hostProfile?.handle || did)); 43 58 let shareUrl = $derived( 44 - typeof window !== 'undefined' ? `${window.location.origin}${eventPath}` : eventPath 59 + shareUrlOverride 60 + ? shareUrlOverride 61 + : typeof window !== 'undefined' 62 + ? `${window.location.origin}${eventPath}` 63 + : eventPath 45 64 ); 46 65 47 66 // Times are always rendered in the viewer's local timezone — the stored UTC ··· 61 80 // the bskyPostRef write — RSVP shares should never overwrite the comments 62 81 // root, even when the RSVPer is the host. 63 82 let canSetEventComments = $state(false); 64 - let isHost = $derived(!!user.did && user.did === did); 83 + let isHost = $derived(!!viewer.did && viewer.did === did); 65 84 let hasComments = $derived( 66 85 !!eventData.bskyPostRef?.showComments && !!eventData.bskyPostRef?.uri 67 86 ); ··· 123 142 124 143 let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`); 125 144 126 - let ogImageUrl = $derived(`${page.url.origin}${page.url.pathname}/og.png`); 145 + let ogImageUrl = $derived(data.ogImage ?? `${page.url.origin}${page.url.pathname}/og.png`); 127 146 128 - let isOwner = $derived(user.isLoggedIn && user.did === did); 147 + let isOwner = $derived(!embedMode && viewer.isLoggedIn && viewer.did === did); 129 148 130 149 let speakers = $derived(data.speakerProfiles ?? []); 131 150 ··· 135 154 let attendeesRef: EventAttendees | undefined = $state(); 136 155 137 156 function handleRsvp(status: 'going' | 'interested') { 138 - if (!user.did) return; 157 + if (!viewer.did) return; 139 158 attendeesRef?.addAttendee({ 140 - did: user.did, 159 + did: viewer.did, 141 160 status, 142 - avatar: user.profile?.avatar, 143 - name: user.profile?.displayName || user.profile?.handle || user.did, 144 - handle: user.profile?.handle, 145 - url: `/${user.profile?.handle || user.did}` 161 + avatar: viewer.avatar, 162 + name: viewer.displayName || viewer.handle || viewer.did, 163 + handle: viewer.handle, 164 + url: `/${viewer.handle || viewer.did}` 146 165 }); 147 166 if (status === 'interested') return; 148 167 shareModalTitle = "You're going!"; ··· 152 171 } 153 172 154 173 function handleRsvpCancel() { 155 - if (!user.did) return; 156 - attendeesRef?.removeAttendee(user.did); 174 + if (!viewer.did) return; 175 + attendeesRef?.removeAttendee(viewer.did); 157 176 } 158 177 </script> 159 178 ··· 218 237 {rkey} 219 238 eventName={eventData.name} 220 239 {hostProfile} 240 + {adapter} 241 + {viewer} 221 242 /> 222 243 {/if} 223 244 {/if} ··· 268 289 initialRsvpStatus={data.viewerRsvpStatus} 269 290 initialRsvpRkey={data.viewerRsvpRkey} 270 291 spaceUri={data.spaceUri ?? null} 292 + {adapter} 293 + {viewer} 271 294 onrsvp={handleRsvp} 272 295 oncancel={handleRsvpCancel} 273 296 /> ··· 387 410 eventDid={did} 388 411 eventRkey={rkey} 389 412 eventDescription={eventData.description} 413 + {adapter} 414 + {viewer} 390 415 />
+15 -16
src/lib/components/PostToBlueskyModal.svelte
··· 10 10 import type { JSONContent, SvelteTiptap } from '@foxui/text'; 11 11 import type { Readable } from 'svelte/store'; 12 12 import { get } from 'svelte/store'; 13 - import { putRecord, getRecord, createRecord, uploadBlob } from '$lib/atproto/methods'; 14 - import { user } from '$lib/atproto/auth.svelte'; 13 + import { getRecord } from '$lib/atproto/methods'; 15 14 import { notifyContrailOfUpdate } from '$lib/contrail'; 15 + import type { EditorAdapter, EditorViewer } from '$lib/components/editor/adapter'; 16 16 17 17 let { 18 18 open = $bindable(false), ··· 24 24 eventDescription, 25 25 ogImageUrl, 26 26 initialText, 27 + adapter, 28 + viewer, 27 29 onPosted 28 30 }: { 29 31 open: boolean; ··· 35 37 eventDescription?: string; 36 38 ogImageUrl?: string; 37 39 initialText: string; 40 + adapter: EditorAdapter; 41 + viewer: EditorViewer; 38 42 onPosted?: (ref: { uri: string; cid: string; showComments: boolean }) => void; 39 43 } = $props(); 40 44 ··· 91 95 const blob = await resp.blob(); 92 96 if (!blob.type.startsWith('image/')) return null; 93 97 if (blob.size > 1_000_000) return null; 94 - const result = await uploadBlob({ blob }); 98 + const result = await adapter.uploadBlob(blob); 95 99 return { 96 100 $type: result.$type, 97 101 ref: result.ref, ··· 105 109 } 106 110 107 111 async function handlePost() { 108 - if (!user.did || posting) return; 112 + if (!viewer.did || posting) return; 109 113 110 114 // Read the editor's live JSON directly — postContent only updates on the 111 115 // editor's onupdate callback and can lag behind setContent prefills. ··· 146 150 }; 147 151 if (facets.length > 0) postRecord.facets = facets; 148 152 149 - const postResp = await createRecord({ 153 + const postResp = await adapter.createRecord({ 150 154 collection: 'app.bsky.feed.post', 151 155 record: postRecord 152 156 }); 153 - const postRespData = postResp.data as 154 - | { uri?: string; cid?: string; error?: string; message?: string } 155 - | undefined; 156 - if (!postRespData?.uri || !postRespData?.cid) { 157 - console.error('PostToBlueskyModal: PDS response from putRecord (post)', postRespData); 157 + if (!postResp.uri || !postResp.cid) { 158 + console.error('PostToBlueskyModal: PDS response missing uri/cid', postResp); 158 159 throw new Error( 159 - postRespData?.message || 160 - postRespData?.error || 161 - 'PDS rejected the post — try logging out and back in to refresh permissions' 160 + 'PDS rejected the post — try logging out and back in to refresh permissions' 162 161 ); 163 162 } 164 - const postUri = postRespData.uri; 165 - const postCid = postRespData.cid; 163 + const postUri = postResp.uri; 164 + const postCid = postResp.cid; 166 165 167 166 if (canSetEventComments) { 168 167 const fresh = await getRecord({ ··· 179 178 showComments 180 179 } 181 180 }; 182 - await putRecord({ 181 + await adapter.putRecord({ 183 182 collection: 'community.lexicon.calendar.event', 184 183 rkey: eventRkey, 185 184 record: updatedRecord
+11 -5
src/lib/components/ShareModal.svelte
··· 1 1 <script lang="ts"> 2 2 import { Modal, Button, Avatar } from '@foxui/core'; 3 3 import { LinkCard } from '@foxui/social'; 4 - import { user } from '$lib/atproto/auth.svelte'; 5 4 import PostToBlueskyModal from '$lib/components/PostToBlueskyModal.svelte'; 5 + import type { EditorAdapter, EditorViewer } from '$lib/components/editor/adapter'; 6 6 7 7 let { 8 8 open = $bindable(false), ··· 15 15 eventDid, 16 16 eventRkey, 17 17 eventDescription, 18 + adapter, 19 + viewer, 18 20 onPosted 19 21 }: { 20 22 open: boolean; ··· 27 29 eventDid?: string; 28 30 eventRkey?: string; 29 31 eventDescription?: string; 32 + adapter: EditorAdapter; 33 + viewer: EditorViewer; 30 34 onPosted?: (ref: { uri: string; cid: string; showComments: boolean }) => void; 31 35 } = $props(); 32 36 ··· 56 60 let blueskyButton: HTMLElement | null = $state(null); 57 61 58 62 let canPostDirectly = $derived( 59 - !!eventDid && !!eventRkey && !!eventName && user.isLoggedIn 63 + !!eventDid && !!eventRkey && !!eventName && viewer.isLoggedIn 60 64 ); 61 65 62 66 let blueskyIntentUrl = $derived( ··· 87 91 <div 88 92 class="bg-base-200 dark:bg-base-950/30 border-base-400/40 dark:border-base-700 mb-6 overflow-hidden rounded-xl border px-4 py-3 text-left" 89 93 > 90 - {#if user.profile} 94 + {#if viewer.isLoggedIn} 91 95 <div class="flex items-center gap-2 pb-4"> 92 - <Avatar src={user.profile.avatar} alt="" class="size-6" /> 96 + <Avatar src={viewer.avatar} alt="" class="size-6" /> 93 97 <span class="text-base-700 dark:text-base-200 text-sm font-medium" 94 - >{user.profile.handle}</span 98 + >{viewer.handle ?? viewer.did}</span 95 99 > 96 100 </div> 97 101 {/if} ··· 149 153 {eventDescription} 150 154 {ogImageUrl} 151 155 initialText={textBeforeUrl ?? eventName} 156 + {adapter} 157 + {viewer} 152 158 onPosted={handlePostedFromInner} 153 159 /> 154 160 {/if}
+25 -4
src/lib/components/editor/adapter.ts
··· 1 1 import { goto } from '$app/navigation'; 2 - import { putRecord, deleteRecord, uploadBlob } from '$lib/atproto/methods'; 2 + import { putRecord, createRecord, deleteRecord, uploadBlob } from '$lib/atproto/methods'; 3 3 import { atProtoLoginModalState } from '$lib/components/LoginModal.svelte'; 4 4 5 5 export type EditorBlobRef = { ··· 28 28 rkey: string; 29 29 record: Record<string, unknown>; 30 30 }): Promise<{ uri: string }>; 31 + createRecord(opts: { 32 + collection: string; 33 + rkey?: string; 34 + record: Record<string, unknown>; 35 + }): Promise<{ uri: string; cid?: string }>; 31 36 deleteRecord(opts: { collection: string; rkey: string }): Promise<void>; 32 37 uploadBlob(blob: Blob): Promise<EditorBlobRef>; 33 38 onSaved(result: { uri: string; rkey: string; isNew: boolean }): void; ··· 58 63 if (!viewer.did) throw new Error('Not logged in'); 59 64 return { uri: `at://${viewer.did}/${collection}/${rkey}` }; 60 65 }, 66 + async createRecord({ collection, rkey, record }) { 67 + const response = await createRecord({ 68 + collection: collection as Parameters<typeof createRecord>[0]['collection'], 69 + rkey, 70 + record 71 + }); 72 + if (!response.ok) throw new Error('createRecord failed'); 73 + const data = response.data as { uri: string; cid?: string }; 74 + return { uri: data.uri, cid: data.cid }; 75 + }, 61 76 async deleteRecord({ collection, rkey }) { 62 77 await deleteRecord({ 63 78 collection: collection as Parameters<typeof deleteRecord>[0]['collection'], ··· 103 118 return { 104 119 features: { delete: false, recurring: true, privateMode: false }, 105 120 async putRecord({ collection, rkey, record }) { 106 - const result = await blento().putRecord({ collection, rkey, record }); 121 + const plain = JSON.parse(JSON.stringify(record)) as Record<string, unknown>; 122 + const result = await blento().putRecord({ collection, rkey, record: plain }); 107 123 return { uri: result.uri }; 124 + }, 125 + async createRecord({ collection, rkey, record }) { 126 + const plain = JSON.parse(JSON.stringify(record)) as Record<string, unknown>; 127 + const result = await blento().createRecord({ collection, rkey, record: plain }); 128 + return { uri: result.uri, cid: result.cid }; 108 129 }, 109 130 async deleteRecord({ collection, rkey }) { 110 131 await blento().deleteRecord({ collection, rkey }); ··· 116 137 onSaved(result) { 117 138 try { 118 139 blento().notify('event-created', result); 119 - } catch { 120 - // Blento not present (e.g. preview); swallow. 140 + } catch (e) { 141 + console.error('[blento adapter] notify failed', e); 121 142 } 122 143 onAfterSave?.(result); 123 144 },
+8 -1
src/lib/components/event-view/InviteShareFlow.svelte
··· 4 4 import ShareModal from '$lib/components/ShareModal.svelte'; 5 5 import { datetimeLocalToISO } from '$lib/date-format'; 6 6 import type { HostProfile } from '$lib/contrail'; 7 + import type { EditorAdapter, EditorViewer } from '$lib/components/editor/adapter'; 7 8 8 9 let { 9 10 spaceUri, ··· 11 12 did, 12 13 rkey, 13 14 eventName, 14 - hostProfile 15 + hostProfile, 16 + adapter, 17 + viewer 15 18 }: { 16 19 spaceUri: string; 17 20 spaceKey: string; ··· 19 22 rkey: string; 20 23 eventName: string; 21 24 hostProfile: HostProfile | null | undefined; 25 + adapter: EditorAdapter; 26 + viewer: EditorViewer; 22 27 } = $props(); 23 28 24 29 let inviteUrl: string | null = $state(null); ··· 164 169 title="Invite link" 165 170 shareText={`You're invited to "${eventName}".\n\n${inviteUrl}`} 166 171 {eventName} 172 + {adapter} 173 + {viewer} 167 174 /> 168 175 {/if}
+13 -1
src/routes/(app)/p/[actor]/e/[rkey]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import EventView from '$lib/components/EventView.svelte'; 3 + import { user } from '$lib/atproto/auth.svelte'; 4 + import { createInAppAdapter } from '$lib/components/editor/adapter'; 5 + 3 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 })); 4 16 </script> 5 17 6 - <EventView {data} /> 18 + <EventView {data} {adapter} {viewer} />
+12 -1
src/routes/(app)/p/[actor]/e/[rkey]/s/[skey]/+page.svelte
··· 5 5 import EventView from '$lib/components/EventView.svelte'; 6 6 import { redeemInvite } from '$lib/spaces/server/spaces.remote'; 7 7 import { atProtoLoginModalState } from '$lib/components/LoginModal.svelte'; 8 + import { user } from '$lib/atproto/auth.svelte'; 9 + import { createInAppAdapter } from '$lib/components/editor/adapter'; 8 10 9 11 let { data } = $props(); 12 + 13 + let viewer = $derived({ 14 + isLoggedIn: user.isLoggedIn, 15 + did: user.did ?? null, 16 + handle: user.profile?.handle, 17 + displayName: user.profile?.displayName, 18 + avatar: user.profile?.avatar 19 + }); 20 + let adapter = $derived(createInAppAdapter({ viewer })); 10 21 11 22 let inviteBusy = $state(false); 12 23 let inviteError: string | null = $state(null); ··· 70 81 <p class="text-base-500 text-sm">Redeeming invite…</p> 71 82 </div> 72 83 {/if} 73 - <EventView {data} /> 84 + <EventView {data} {adapter} {viewer} /> 74 85 {#if data.isOwner} 75 86 <div class="mx-auto max-w-3xl px-4 pb-12"> 76 87 <a
+13
src/routes/(app)/p/atmosphereconf.org/ScheduleEventCell.svelte
··· 10 10 import { Modal, Button } from '@foxui/core'; 11 11 import EventRsvp from '$lib/components/EventRsvp.svelte'; 12 12 import VodPlayer from '$lib/components/VodPlayer.svelte'; 13 + import { user } from '$lib/atproto/auth.svelte'; 14 + import { createInAppAdapter } from '$lib/components/editor/adapter'; 15 + 16 + let viewer = $derived({ 17 + isLoggedIn: user.isLoggedIn, 18 + did: user.did ?? null, 19 + handle: user.profile?.handle, 20 + displayName: user.profile?.displayName, 21 + avatar: user.profile?.avatar 22 + }); 23 + let adapter = $derived(createInAppAdapter({ viewer })); 13 24 14 25 let { 15 26 event, ··· 91 102 eventCid={event.cid ?? null} 92 103 {initialRsvpStatus} 93 104 {initialRsvpRkey} 105 + {adapter} 106 + {viewer} 94 107 onlogin={() => (modalOpen = false)} 95 108 onrsvp={(status, key) => { onrsvpchange?.(event.uri, status, key); modalOpen = false; }} 96 109 oncancel={() => { onrsvpchange?.(event.uri, null); }}
+16
src/routes/embed/create/+page.svelte
··· 2 2 import EventEditor from '$lib/components/EventEditor.svelte'; 3 3 import { onMount } from 'svelte'; 4 4 import { createBlentoAdapter, type EditorViewer } from '$lib/components/editor/adapter'; 5 + import type { EventTheme } from '$lib/theme'; 5 6 6 7 let { data } = $props(); 7 8 ··· 24 25 }); 25 26 let adapter = $derived(createBlentoAdapter({ viewer })); 26 27 28 + // Inherit the embedder's theme so new events default to the surrounding 29 + // palette rather than a random accent. 30 + let initialTheme = $state<Partial<EventTheme> | undefined>(undefined); 31 + 27 32 onMount(() => { 33 + const params = new URLSearchParams(window.location.search); 34 + const accent = params.get('accent'); 35 + const base = params.get('base'); 36 + if (accent || base) { 37 + initialTheme = { 38 + ...(accent ? { accentColor: accent } : {}), 39 + ...(base ? { baseColor: base } : {}) 40 + }; 41 + } 42 + 28 43 if (!window.Blento) return; 29 44 let unsubscribe: (() => void) | undefined; 30 45 let cancelled = false; ··· 75 90 rkey={data.rkey} 76 91 {adapter} 77 92 {viewer} 93 + {initialTheme} 78 94 /> 79 95 {/if}
+104
src/routes/embed/full/[actor]/[rkey]/+page.server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import type { ActorIdentifier } from '@atcute/lexicons'; 3 + import { getActor } from '$lib/actor'; 4 + import { 5 + flattenEventRecord, 6 + getEventRecordFromContrail, 7 + getHostProfile, 8 + getProfileBlobUrl, 9 + getProfileFromContrail, 10 + getRsvpStatus, 11 + getServerClient, 12 + getViewerRsvpFromContrail, 13 + listEventAttendeesFromContrail, 14 + RSVP_HYDRATE_LIMIT 15 + } from '$lib/contrail'; 16 + import { vodFromAtUri } from '$lib/vods'; 17 + 18 + export async function load({ params, url, platform }) { 19 + const client = getServerClient(platform!.env.DB); 20 + const { rkey } = params; 21 + const did = await getActor(params.actor); 22 + 23 + if (!did || !rkey) { 24 + throw error(404, 'Event not found'); 25 + } 26 + 27 + try { 28 + const eventRecord = await getEventRecordFromContrail(client, { 29 + did, 30 + rkey, 31 + hydrateRsvps: RSVP_HYDRATE_LIMIT, 32 + profiles: true 33 + }); 34 + 35 + const eventData = eventRecord ? flattenEventRecord(eventRecord) : null; 36 + if (!eventData) throw error(404, 'Event not found'); 37 + 38 + const fullEventRecord = eventRecord!; 39 + const isAtmosphereconf = !!(eventData.additionalData as Record<string, unknown> | undefined) 40 + ?.isAtmosphereconf; 41 + const speakers = 42 + ((eventData.additionalData as Record<string, unknown> | undefined)?.speakers as 43 + | Array<{ id: string; name: string }> 44 + | undefined) ?? []; 45 + const vodAtUri = (eventData.additionalData as Record<string, unknown> | undefined) 46 + ?.vodAtUri as string | undefined; 47 + const vod = vodAtUri ? vodFromAtUri(vodAtUri) : null; 48 + 49 + const viewerDid = url.searchParams.get('did') ?? null; 50 + 51 + const [attendees, viewerRsvpRecord, parentEvent, ...speakerProfiles] = await Promise.all([ 52 + listEventAttendeesFromContrail(client, fullEventRecord.uri), 53 + viewerDid 54 + ? getViewerRsvpFromContrail(client, { 55 + eventUri: fullEventRecord.uri, 56 + actor: viewerDid as ActorIdentifier 57 + }) 58 + : null, 59 + isAtmosphereconf 60 + ? getEventRecordFromContrail(client, { 61 + did: 'did:plc:lehcqqkwzcwvjvw66uthu5oq', 62 + rkey: '3lte3c7x43l2e', 63 + profiles: true 64 + }) 65 + .then((r) => (r ? flattenEventRecord(r) : null)) 66 + .catch(() => null) 67 + : null, 68 + ...speakers.map((s) => 69 + s.id 70 + ? getProfileFromContrail(client, s.id as ActorIdentifier) 71 + .then((p) => ({ 72 + id: s.id, 73 + name: s.name, 74 + avatar: p?.value?.avatar ? getProfileBlobUrl(p.did, p.value.avatar) : undefined, 75 + handle: p?.handle || s.id 76 + })) 77 + .catch(() => ({ id: s.id, name: s.name, avatar: undefined, handle: s.id })) 78 + : Promise.resolve({ id: undefined, name: s.name, avatar: undefined, handle: undefined }) 79 + ) 80 + ]); 81 + 82 + return { 83 + ogImage: `${url.origin}/p/${params.actor}/e/${rkey}/og.png`, 84 + eventData, 85 + actorDid: did, 86 + rkey, 87 + hostProfile: getHostProfile(did, fullEventRecord.profiles) ?? null, 88 + attendees, 89 + viewerRsvpStatus: getRsvpStatus(viewerRsvpRecord?.value?.status), 90 + viewerRsvpRkey: viewerRsvpRecord?.rkey ?? null, 91 + parentEvent, 92 + vod, 93 + speakerProfiles: speakerProfiles as Array<{ 94 + id?: string; 95 + name: string; 96 + avatar?: string; 97 + handle?: string; 98 + }> 99 + }; 100 + } catch (e) { 101 + if (e && typeof e === 'object' && 'status' in e) throw e; 102 + throw error(404, 'Event not found'); 103 + } 104 + }
+82
src/routes/embed/full/[actor]/[rkey]/+page.svelte
··· 1 + <script lang="ts"> 2 + import EventView from '$lib/components/EventView.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 + let shareUrlOverride = $state<string | undefined>(undefined); 18 + 19 + let viewer = $derived<EditorViewer>({ 20 + isLoggedIn: !!session, 21 + did: session?.did ?? null, 22 + handle: session?.handle, 23 + displayName: session?.displayName, 24 + avatar: session?.avatar 25 + }); 26 + let adapter = $derived(createBlentoAdapter({ viewer })); 27 + 28 + onMount(() => { 29 + const params = new URLSearchParams(window.location.search); 30 + const shareUrl = params.get('share_url'); 31 + if (shareUrl) shareUrlOverride = shareUrl; 32 + 33 + if (!window.Blento) { 34 + ready = true; 35 + return; 36 + } 37 + let unsubscribe: (() => void) | undefined; 38 + let cancelled = false; 39 + (async () => { 40 + try { 41 + await window.Blento!.ready; 42 + } catch { 43 + ready = true; 44 + return; 45 + } 46 + if (cancelled) return; 47 + session = window.Blento!.getSession(); 48 + ready = true; 49 + unsubscribe = window.Blento!.on('session', (s) => { 50 + session = s; 51 + }); 52 + })(); 53 + return () => { 54 + cancelled = true; 55 + unsubscribe?.(); 56 + }; 57 + }); 58 + </script> 59 + 60 + <svelte:head> 61 + <!-- Apply theme classes before paint to avoid flash --> 62 + <script> 63 + var p = new URLSearchParams(location.search); 64 + var h = document.documentElement; 65 + var b = p.get('base'); 66 + if (b) h.classList.add(b); 67 + var a = p.get('accent'); 68 + if (a) h.classList.add(a); 69 + if (p.get('dark') === '1') h.classList.add('dark'); 70 + </script> 71 + <script src="https://blento.app/embed/v0/sdk.js"></script> 72 + </svelte:head> 73 + 74 + {#if !ready} 75 + <div class="bg-base-50 dark:bg-base-950 flex h-full items-center justify-center"> 76 + <div 77 + class="border-base-300 dark:border-base-700 border-t-accent-600 size-6 animate-spin rounded-full border-2" 78 + ></div> 79 + </div> 80 + {:else} 81 + <EventView {data} {adapter} {viewer} embedMode {shareUrlOverride} /> 82 + {/if}
+286 -77
src/routes/embed/p/[actor]/e/[rkey]/+page.svelte
··· 135 135 <script> 136 136 var p = new URLSearchParams(location.search); 137 137 var h = document.documentElement; 138 - var b = p.get('base'); if (b) h.classList.add(b); 139 - var a = p.get('accent'); if (a) h.classList.add(a); 138 + var bases = ['gray','zinc','neutral','stone','slate','mist','olive','mauve','taupe']; 139 + var accents = ['red','orange','amber','yellow','lime','green','emerald','teal','cyan','sky','blue','indigo','violet','purple','fuchsia','pink','rose']; 140 + var b = p.get('base'); 141 + if (b) { bases.forEach(function (c) { h.classList.remove(c); }); h.classList.add(b); } 142 + var a = p.get('accent'); 143 + if (a) { accents.forEach(function (c) { h.classList.remove(c); }); h.classList.add(a); } 140 144 if (p.get('dark') === '1') h.classList.add('dark'); 141 145 </script> 142 146 <script src="https://blento.app/embed/v0/sdk.js"></script> 143 147 </svelte:head> 144 148 145 - <div class="@container bg-base-200 dark:bg-base-950/50 text-base-900 dark:text-base-50 flex h-full items-center gap-4.5 overflow-hidden px-5 py-3 @sm:gap-5 @sm:px-6 @sm:py-4 @lg:gap-8 @lg:px-8 @lg:py-6"> 146 - <!-- Thumbnail --> 147 - <a href={eventUrl} target="_blank" rel="noopener noreferrer" class="shrink-0"> 148 - <div class="aspect-square h-[calc(90cqh-1.5rem)] max-h-36 overflow-hidden rounded-xl @sm:h-[calc(90cqh-2rem)] @sm:rounded-2xl @lg:h-[calc(90cqh-3rem)] @lg:max-h-48"> 149 + <div class="embed-root bg-base-200 dark:bg-base-950/50 text-base-900 dark:text-base-50"> 150 + <div class="embed-frame"> 151 + <a href={eventUrl} target="_blank" rel="noopener noreferrer" class="embed-thumb"> 149 152 {#if data.thumbnailUrl} 150 153 <img 151 154 src={data.thumbnailUrl} ··· 163 166 /> 164 167 </div> 165 168 {/if} 166 - </div> 167 - </a> 168 - 169 - <!-- Details + RSVP --> 170 - <div class="flex min-w-0 flex-1 flex-col justify-center gap-2 @sm:gap-3 @lg:gap-4"> 171 - <a href={eventUrl} target="_blank" rel="noopener noreferrer" class="min-w-0"> 172 - <h2 class="line-clamp-2 text-xs leading-snug font-semibold @sm:text-base @lg:text-xl">{data.eventData.name}</h2> 173 - <p class="text-base-500 dark:text-base-400 mt-1 truncate text-[11px] @sm:mt-1.5 @sm:text-sm @lg:mt-2 @lg:text-base"> 174 - {formatDate(startDate)}, {formatTime(startDate)}{#if endDate && isSameDay} - {formatTime(endDate)}{/if} 175 - </p> 176 - {#if location} 177 - <p class="text-base-500 dark:text-base-400 truncate text-[11px] @sm:text-sm @lg:text-base">{location}</p> 178 - {/if} 179 169 </a> 180 170 181 - <!-- RSVP --> 182 - {#if !data.viewerDid} 183 - <div class="mt-2 @sm:mt-3 @lg:mt-4"> 184 - <button 185 - onclick={handleLogin} 186 - class="bg-accent-600 hover:bg-accent-700 w-full cursor-pointer rounded-md px-2.5 py-1 text-[11px] font-medium text-white transition-colors @sm:rounded-lg @sm:px-4 @sm:py-1.5 @sm:text-sm @lg:px-5 @lg:py-2 @lg:text-base" 187 - >Log in to RSVP</button> 188 - </div> 189 - {:else} 190 - <div class="border-base-300 dark:border-base-800 bg-base-100 dark:bg-base-900/50 mt-2 flex flex-col justify-center rounded-lg border px-2 py-2 @sm:mt-3 @sm:rounded-xl @sm:px-3 @sm:py-3 @lg:mt-4 @lg:rounded-2xl @lg:px-4 @lg:py-4"> 191 - {#if rsvpStatus === 'going'} 192 - <div class="flex items-center justify-between"> 193 - <div class="flex items-center gap-1.5 @sm:gap-2.5 @lg:gap-3"> 194 - <div class="flex size-5 items-center justify-center rounded-full bg-green-100 @sm:size-6 @lg:size-8 dark:bg-green-900/30"> 195 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3 @sm:size-3.5 @lg:size-4 text-green-600 dark:text-green-400"> 196 - <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd" /> 197 - </svg> 198 - </div> 199 - <span class="text-base-900 dark:text-base-50 text-[11px] font-semibold @sm:text-sm @lg:text-base">You're Going</span> 200 - </div> 201 - <button onclick={cancelRsvp} disabled={submitting} class="text-base-400 hover:text-base-600 dark:text-base-500 dark:hover:text-base-300 cursor-pointer transition-colors"> 202 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5 @sm:size-4 @lg:size-5"> 203 - <path 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" /> 204 - </svg> 205 - </button> 206 - </div> 207 - {:else if rsvpStatus === 'interested'} 208 - <div class="flex items-center justify-between"> 209 - <div class="flex items-center gap-1.5 @sm:gap-2.5 @lg:gap-3"> 210 - <div class="flex size-5 items-center justify-center rounded-full bg-amber-100 @sm:size-6 @lg:size-8 dark:bg-amber-900/30"> 211 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3 @sm:size-3.5 @lg:size-4 text-amber-600 dark:text-amber-400"> 212 - <path fill-rule="evenodd" d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401Z" clip-rule="evenodd" /> 213 - </svg> 214 - </div> 215 - <span class="text-base-900 dark:text-base-50 text-[11px] font-semibold @sm:text-sm @lg:text-base">You're Interested</span> 216 - </div> 217 - <button onclick={cancelRsvp} disabled={submitting} class="text-base-400 hover:text-base-600 dark:text-base-500 dark:hover:text-base-300 cursor-pointer transition-colors"> 218 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5 @sm:size-4 @lg:size-5"> 219 - <path 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" /> 220 - </svg> 221 - </button> 171 + <div class="embed-info"> 172 + <a href={eventUrl} target="_blank" rel="noopener noreferrer" class="embed-meta"> 173 + <h2 class="embed-title">{data.eventData.name}</h2> 174 + <p class="embed-line text-base-500 dark:text-base-400"> 175 + {formatDate(startDate)}, {formatTime(startDate)}{#if endDate && isSameDay} - {formatTime(endDate)}{/if} 176 + </p> 177 + {#if location} 178 + <p class="embed-line text-base-500 dark:text-base-400">{location}</p> 179 + {/if} 180 + </a> 181 + 182 + {#if !data.viewerDid} 183 + <div class="embed-rsvp-wrap"> 184 + <button 185 + onclick={handleLogin} 186 + class="embed-btn embed-btn-primary bg-accent-600 hover:bg-accent-700 text-white" 187 + >Log in to RSVP</button> 222 188 </div> 223 189 {:else} 224 - <div class="flex gap-1.5 @sm:gap-2 @lg:gap-3"> 225 - <button 226 - onclick={() => submitRsvp('going')} 227 - disabled={submitting} 228 - class="bg-accent-600 hover:bg-accent-700 flex-1 cursor-pointer rounded-md px-2.5 py-1 text-[11px] font-medium text-white transition-colors disabled:opacity-50 @sm:rounded-lg @sm:px-4 @sm:py-1.5 @sm:text-sm @lg:px-5 @lg:py-2 @lg:text-base" 229 - >Going</button> 230 - <button 231 - onclick={() => submitRsvp('interested')} 232 - disabled={submitting} 233 - class="bg-base-300 dark:bg-base-800 hover:bg-base-400 dark:hover:bg-base-700 text-base-700 dark:text-base-300 cursor-pointer rounded-md px-2 py-1 transition-colors disabled:opacity-50 @sm:flex-1 @sm:rounded-lg @sm:px-4 @sm:py-1.5 @sm:text-sm @lg:px-5 @lg:py-2 @lg:text-base" 234 - > 235 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5 @sm:hidden"> 236 - <path fill-rule="evenodd" d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401Z" clip-rule="evenodd" /> 237 - </svg> 238 - <span class="hidden text-[11px] font-medium @sm:inline @sm:text-sm @lg:text-base">Interested</span> 239 - </button> 190 + <div class="embed-rsvp-card border-base-300 dark:border-base-800 bg-base-100 dark:bg-base-900/50"> 191 + {#if rsvpStatus === 'going'} 192 + <div class="embed-status-row"> 193 + <div class="embed-status-left"> 194 + <div class="embed-status-icon bg-green-100 dark:bg-green-900/30"> 195 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="text-green-600 dark:text-green-400"> 196 + <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd" /> 197 + </svg> 198 + </div> 199 + <span class="embed-status-label text-base-900 dark:text-base-50">You're Going</span> 200 + </div> 201 + <button onclick={cancelRsvp} disabled={submitting} aria-label="Cancel RSVP" class="embed-cancel text-base-400 hover:text-base-600 dark:text-base-500 dark:hover:text-base-300"> 202 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> 203 + <path 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" /> 204 + </svg> 205 + </button> 206 + </div> 207 + {:else if rsvpStatus === 'interested'} 208 + <div class="embed-status-row"> 209 + <div class="embed-status-left"> 210 + <div class="embed-status-icon bg-amber-100 dark:bg-amber-900/30"> 211 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="text-amber-600 dark:text-amber-400"> 212 + <path fill-rule="evenodd" d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401Z" clip-rule="evenodd" /> 213 + </svg> 214 + </div> 215 + <span class="embed-status-label text-base-900 dark:text-base-50">You're Interested</span> 216 + </div> 217 + <button onclick={cancelRsvp} disabled={submitting} aria-label="Cancel RSVP" class="embed-cancel text-base-400 hover:text-base-600 dark:text-base-500 dark:hover:text-base-300"> 218 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> 219 + <path 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" /> 220 + </svg> 221 + </button> 222 + </div> 223 + {:else} 224 + <div class="embed-btn-row"> 225 + <button 226 + onclick={() => submitRsvp('going')} 227 + disabled={submitting} 228 + class="embed-btn embed-btn-primary bg-accent-600 hover:bg-accent-700 text-white" 229 + >Going</button> 230 + <button 231 + onclick={() => submitRsvp('interested')} 232 + disabled={submitting} 233 + class="embed-btn embed-btn-secondary bg-base-300 dark:bg-base-800 hover:bg-base-400 dark:hover:bg-base-700 text-base-700 dark:text-base-300" 234 + > 235 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="embed-btn-icon"> 236 + <path fill-rule="evenodd" d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401Z" clip-rule="evenodd" /> 237 + </svg> 238 + <span class="embed-btn-text">Interested</span> 239 + </button> 240 + </div> 241 + {/if} 240 242 </div> 241 243 {/if} 242 244 </div> 243 - {/if} 244 245 </div> 245 246 </div> 247 + 248 + <style> 249 + .embed-root { 250 + container-type: size; 251 + container-name: embed; 252 + width: 100%; 253 + height: 100%; 254 + display: flex; 255 + align-items: center; 256 + justify-content: center; 257 + overflow: hidden; 258 + } 259 + 260 + .embed-frame { 261 + display: flex; 262 + flex-direction: row; 263 + align-items: center; 264 + gap: clamp(0.75rem, 4cqmin, 1.75rem); 265 + padding: clamp(0.625rem, 3cqmin, 1.5rem); 266 + width: 100%; 267 + height: 100%; 268 + box-sizing: border-box; 269 + } 270 + 271 + @container embed (aspect-ratio > 3) { 272 + .embed-frame { 273 + max-width: 36rem; 274 + } 275 + } 276 + 277 + .embed-thumb { 278 + display: block; 279 + flex-shrink: 0; 280 + aspect-ratio: 1; 281 + overflow: hidden; 282 + border-radius: clamp(0.5rem, 2cqmin, 1rem); 283 + width: min(80cqb, 36cqi); 284 + max-width: 9rem; 285 + min-width: 2.5rem; 286 + } 287 + 288 + .embed-info { 289 + flex: 1; 290 + min-width: 0; 291 + display: flex; 292 + flex-direction: column; 293 + justify-content: center; 294 + gap: clamp(0.5rem, 2cqmin, 0.75rem); 295 + } 296 + 297 + .embed-meta { 298 + display: block; 299 + min-width: 0; 300 + } 301 + 302 + .embed-title { 303 + font-size: clamp(0.8125rem, 4cqmin, 1.125rem); 304 + line-height: 1.25; 305 + font-weight: 600; 306 + display: -webkit-box; 307 + -webkit-line-clamp: 2; 308 + line-clamp: 2; 309 + -webkit-box-orient: vertical; 310 + overflow: hidden; 311 + } 312 + 313 + .embed-line { 314 + font-size: clamp(0.6875rem, 2.8cqmin, 0.875rem); 315 + margin-top: 0.2em; 316 + text-overflow: ellipsis; 317 + white-space: nowrap; 318 + overflow: hidden; 319 + } 320 + 321 + .embed-rsvp-wrap { 322 + display: block; 323 + } 324 + 325 + .embed-rsvp-card { 326 + border-width: 1px; 327 + border-style: solid; 328 + border-radius: clamp(0.5rem, 2.5cqmin, 1rem); 329 + padding: clamp(0.5rem, 2cqmin, 0.875rem); 330 + display: flex; 331 + flex-direction: column; 332 + justify-content: center; 333 + } 334 + 335 + .embed-btn-row { 336 + display: flex; 337 + gap: clamp(0.375rem, 1.5cqmin, 0.5rem); 338 + } 339 + 340 + .embed-btn { 341 + flex: 1; 342 + font-size: clamp(0.6875rem, 2.8cqmin, 1rem); 343 + padding: clamp(0.25rem, 1.2cqmin, 0.5rem) clamp(0.625rem, 2.5cqmin, 1.25rem); 344 + border-radius: clamp(0.375rem, 1.6cqmin, 0.625rem); 345 + font-weight: 500; 346 + cursor: pointer; 347 + transition: background-color 150ms; 348 + display: inline-flex; 349 + align-items: center; 350 + justify-content: center; 351 + gap: 0.375rem; 352 + } 353 + 354 + .embed-btn:disabled { 355 + opacity: 0.5; 356 + cursor: default; 357 + } 358 + 359 + .embed-btn-icon { 360 + display: none; 361 + width: clamp(0.875rem, 3.2cqmin, 1.125rem); 362 + height: clamp(0.875rem, 3.2cqmin, 1.125rem); 363 + } 364 + 365 + .embed-btn-text { 366 + display: inline-block; 367 + } 368 + 369 + @container embed (max-width: 360px) { 370 + .embed-btn-text { 371 + display: none; 372 + } 373 + .embed-btn-icon { 374 + display: inline-block; 375 + } 376 + } 377 + 378 + .embed-status-row { 379 + display: flex; 380 + align-items: center; 381 + justify-content: space-between; 382 + gap: clamp(0.5rem, 2cqmin, 0.75rem); 383 + min-width: 0; 384 + } 385 + 386 + .embed-status-left { 387 + display: flex; 388 + align-items: center; 389 + gap: clamp(0.375rem, 1.5cqmin, 0.625rem); 390 + min-width: 0; 391 + flex: 1; 392 + } 393 + 394 + .embed-status-icon { 395 + flex-shrink: 0; 396 + width: clamp(1.25rem, 4.5cqmin, 1.75rem); 397 + height: clamp(1.25rem, 4.5cqmin, 1.75rem); 398 + border-radius: 9999px; 399 + display: flex; 400 + align-items: center; 401 + justify-content: center; 402 + } 403 + 404 + .embed-status-icon svg { 405 + width: 60%; 406 + height: 60%; 407 + } 408 + 409 + .embed-status-label { 410 + font-size: clamp(0.6875rem, 2.8cqmin, 0.875rem); 411 + font-weight: 600; 412 + text-overflow: ellipsis; 413 + white-space: nowrap; 414 + overflow: hidden; 415 + min-width: 0; 416 + } 417 + 418 + .embed-cancel { 419 + flex-shrink: 0; 420 + cursor: pointer; 421 + transition: color 150ms; 422 + display: inline-flex; 423 + align-items: center; 424 + justify-content: center; 425 + background: transparent; 426 + border: 0; 427 + padding: 0.125rem; 428 + } 429 + 430 + .embed-cancel svg { 431 + width: clamp(0.875rem, 3.2cqmin, 1.125rem); 432 + height: clamp(0.875rem, 3.2cqmin, 1.125rem); 433 + } 434 + 435 + @container embed (aspect-ratio < 1.3) { 436 + .embed-frame { 437 + flex-direction: column; 438 + justify-content: center; 439 + max-width: 28rem; 440 + max-height: 100%; 441 + } 442 + .embed-thumb { 443 + width: min(45cqb, 65cqi); 444 + max-width: 18rem; 445 + } 446 + .embed-info { 447 + width: 100%; 448 + flex: 0 1 auto; 449 + } 450 + .embed-meta { 451 + text-align: center; 452 + } 453 + } 454 + </style>