[READ-ONLY] Mirror of https://github.com/flo-bit/atmo-garden. atmo.garden
0

Configure Feed

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

add first atmo.rsvp version

+211
+54
src/lib/atproto/server/feed.remote.ts
··· 108 108 } 109 109 ); 110 110 111 + export const createRsvp = command( 112 + v.object({ 113 + eventUri: v.string(), 114 + eventCid: v.string(), 115 + status: v.optional(v.string()) 116 + }), 117 + async (input) => { 118 + const { locals } = getRequestEvent(); 119 + if (!locals.client || !locals.did) error(401, 'Not authenticated'); 120 + 121 + const rkey = TID.now(); 122 + const res = await locals.client.post('com.atproto.repo.createRecord', { 123 + input: { 124 + repo: locals.did, 125 + collection: 'community.lexicon.calendar.rsvp', 126 + rkey, 127 + record: { 128 + $type: 'community.lexicon.calendar.rsvp', 129 + status: input.status ?? 'community.lexicon.calendar.rsvp#going', 130 + subject: { uri: input.eventUri, cid: input.eventCid }, 131 + createdAt: new Date().toISOString() 132 + } 133 + } 134 + }); 135 + 136 + if (!res.ok) error(res.status, 'Failed to RSVP'); 137 + return { uri: res.data.uri }; 138 + } 139 + ); 140 + 141 + export const deleteRsvp = command( 142 + v.object({ 143 + rsvpUri: v.string() 144 + }), 145 + async (input) => { 146 + const { locals } = getRequestEvent(); 147 + if (!locals.client || !locals.did) error(401, 'Not authenticated'); 148 + 149 + const parts = input.rsvpUri.split('/'); 150 + const rkey = parts[parts.length - 1]; 151 + 152 + const res = await locals.client.post('com.atproto.repo.deleteRecord', { 153 + input: { 154 + repo: locals.did, 155 + collection: 'community.lexicon.calendar.rsvp', 156 + rkey 157 + } 158 + }); 159 + 160 + if (!res.ok) error(res.status, 'Failed to cancel RSVP'); 161 + return { ok: true }; 162 + } 163 + ); 164 + 111 165 export const getPostThread = command( 112 166 v.object({ 113 167 uri: v.string(),
+139
src/lib/components/embed/EventEmbed.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { Button } from '@foxui/core'; 4 + import { CalendarCheck, CalendarX } from '@lucide/svelte'; 5 + import { user } from '$lib/atproto/auth.svelte'; 6 + import { createRsvp, deleteRsvp } from '$lib/atproto/server/feed.remote'; 7 + 8 + const { url }: { url: string } = $props(); 9 + 10 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 + let event = $state<any>(null); 12 + let loading = $state(true); 13 + let rsvpLoading = $state(false); 14 + 15 + // Current user's RSVP state 16 + let myRsvpUri = $state<string | null>(null); 17 + let isRsvpd = $derived(myRsvpUri !== null); 18 + 19 + // Local count adjustments 20 + let goingDelta = $state(0); 21 + 22 + onMount(async () => { 23 + try { 24 + const res = await fetch(`${url}/data.json`); 25 + if (res.ok) { 26 + event = await res.json(); 27 + findMyRsvp(); 28 + } 29 + } catch { 30 + // silent 31 + } finally { 32 + loading = false; 33 + } 34 + }); 35 + 36 + function findMyRsvp() { 37 + if (!user.did || !event?.rsvps) return; 38 + // Check all RSVP status categories 39 + for (const statusKey of Object.keys(event.rsvps)) { 40 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 41 + const rsvp = event.rsvps[statusKey]?.find((r: any) => r.did === user.did); 42 + if (rsvp) { 43 + myRsvpUri = rsvp.uri; 44 + return; 45 + } 46 + } 47 + } 48 + 49 + async function toggleRsvp() { 50 + if (!event?.record || rsvpLoading) return; 51 + rsvpLoading = true; 52 + try { 53 + if (isRsvpd) { 54 + await deleteRsvp({ rsvpUri: myRsvpUri! }); 55 + myRsvpUri = null; 56 + goingDelta--; 57 + } else { 58 + const result = await createRsvp({ 59 + eventUri: event.uri, 60 + eventCid: event.cid 61 + }); 62 + myRsvpUri = result.uri; 63 + goingDelta++; 64 + } 65 + } catch (e) { 66 + console.error('Failed to toggle RSVP:', e); 67 + } finally { 68 + rsvpLoading = false; 69 + } 70 + } 71 + 72 + function formatDate(dateStr: string): string { 73 + const d = new Date(dateStr); 74 + return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }); 75 + } 76 + 77 + function formatTime(dateStr: string): string { 78 + return new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 79 + } 80 + 81 + function getThumbnail(): string | null { 82 + if (!event?.record?.media) return null; 83 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 84 + const thumb = event.record.media.find((m: any) => m.role === 'thumbnail'); 85 + if (!thumb?.content?.ref?.$link) return null; 86 + return `https://cdn.bsky.app/img/avatar/plain/${event.did}/${thumb.content.ref.$link}@jpeg`; 87 + } 88 + </script> 89 + 90 + {#if loading} 91 + <div class="border-base-300 dark:border-base-600/30 bg-base-200/50 dark:bg-base-800/50 flex h-24 items-center justify-center rounded-2xl border"> 92 + <div class="text-base-400 animate-pulse text-sm">Loading event...</div> 93 + </div> 94 + {:else if event?.record} 95 + <div class="border-base-300 dark:border-base-600/30 bg-base-200/50 dark:bg-base-800/50 overflow-hidden rounded-2xl border"> 96 + <a 97 + href={url} 98 + target="_blank" 99 + rel="noopener noreferrer" 100 + class="hover:bg-base-200 dark:hover:bg-base-800 flex items-center gap-4 p-4 transition-colors" 101 + > 102 + {#if getThumbnail()} 103 + <img src={getThumbnail()} alt="" class="size-18 shrink-0 rounded-xl object-cover" /> 104 + {/if} 105 + <div class="min-w-0 flex-1"> 106 + <h3 class="text-base-900 dark:text-base-100 line-clamp-2 text-lg font-bold leading-tight"> 107 + {event.record.name} 108 + </h3> 109 + <div class="text-base-500 dark:text-base-400 mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs"> 110 + {#if event.record.startsAt} 111 + <span>{formatDate(event.record.startsAt)}, {formatTime(event.record.startsAt)}</span> 112 + {/if} 113 + {#if (event.rsvpsGoingCount + goingDelta) > 0 || event.rsvpsInterestedCount > 0} 114 + <span>{event.rsvpsGoingCount + goingDelta} going, {event.rsvpsInterestedCount} interested</span> 115 + {/if} 116 + </div> 117 + </div> 118 + </a> 119 + {#if user.did} 120 + <div class="border-base-300/50 dark:border-base-600/20 border-t px-4 py-2.5"> 121 + <Button 122 + variant={isRsvpd ? 'outline' : 'default'} 123 + size="sm" 124 + onclick={toggleRsvp} 125 + disabled={rsvpLoading} 126 + class="w-full gap-1.5" 127 + > 128 + {#if isRsvpd} 129 + <CalendarX size={14} /> 130 + Cancel RSVP 131 + {:else} 132 + <CalendarCheck size={14} /> 133 + RSVP Going 134 + {/if} 135 + </Button> 136 + </div> 137 + {/if} 138 + </div> 139 + {/if}
+11
src/lib/components/embed/special/AtmoRsvpEmbed.svelte
··· 1 + <script lang="ts"> 2 + import type { EmbedExternalData } from '../types'; 3 + import EventEmbed from '../EventEmbed.svelte'; 4 + 5 + const { data }: { data: EmbedExternalData } = $props(); 6 + 7 + // Normalize URL: strip trailing slash 8 + const url = data.external.href.replace(/\/$/, ''); 9 + </script> 10 + 11 + <EventEmbed {url} />
+7
src/lib/components/embed/special/index.ts
··· 2 2 import type { EmbedExternalData } from '../types'; 3 3 import YouTubeEmbed from './YouTubeEmbed.svelte'; 4 4 import TenorEmbed from './TenorEmbed.svelte'; 5 + import AtmoRsvpEmbed from './AtmoRsvpEmbed.svelte'; 5 6 6 7 export type SpecialEmbed = { 7 8 match: (data: EmbedExternalData) => boolean; ··· 21 22 return /^https?:\/\/(www\.|media\.)?tenor\.com\//.test(data.external.href); 22 23 }, 23 24 component: TenorEmbed 25 + }, 26 + { 27 + match: (data) => { 28 + return /^https?:\/\/(www\.)?atmo\.rsvp\/p\//.test(data.external.href); 29 + }, 30 + component: AtmoRsvpEmbed 24 31 } 25 32 ]; 26 33