your personal website on atproto - mirror blento.app

Merge pull request #227 from flo-bit/events-set-date

more event stuff

authored by

Florian and committed by
GitHub
6ff9dbad 51cc3f92

+1263 -265
+34 -34
.claude/settings.local.json
··· 1 1 { 2 - "permissions": { 3 - "allow": [ 4 - "Bash(pnpm check:*)", 5 - "mcp__ide__getDiagnostics", 6 - "mcp__plugin_svelte_svelte__svelte-autofixer", 7 - "mcp__plugin_svelte_svelte__list-sections", 8 - "Bash(pkill:*)", 9 - "Bash(timeout 8 pnpm dev:*)", 10 - "Bash(git checkout:*)", 11 - "Bash(npx svelte-kit:*)", 12 - "Bash(ls:*)", 13 - "Bash(pnpm format:*)", 14 - "Bash(pnpm add:*)", 15 - "WebSearch", 16 - "WebFetch(domain:github.com)", 17 - "WebFetch(domain:flipclockjs.com)", 18 - "WebFetch(domain:codepen.io)", 19 - "WebFetch(domain:flo-bit.dev)", 20 - "Bash(pnpm install)", 21 - "Bash(pnpm install:*)", 22 - "Bash(pnpm config:*)", 23 - "Bash(lsof:*)", 24 - "Bash(pnpm dev)", 25 - "Bash(pnpm exec svelte-kit:*)", 26 - "Bash(pnpm build:*)", 27 - "Bash(pnpm remove:*)", 28 - "Bash(grep:*)", 29 - "Bash(find:*)", 30 - "Bash(npx prettier:*)", 31 - "Bash(node -e:*)", 32 - "mcp__plugin_svelte_svelte__get-documentation", 33 - "WebFetch(domain:bits-ui.com)" 34 - ] 35 - } 2 + "permissions": { 3 + "allow": [ 4 + "Bash(pnpm check:*)", 5 + "mcp__ide__getDiagnostics", 6 + "mcp__plugin_svelte_svelte__svelte-autofixer", 7 + "mcp__plugin_svelte_svelte__list-sections", 8 + "Bash(pkill:*)", 9 + "Bash(timeout 8 pnpm dev:*)", 10 + "Bash(git checkout:*)", 11 + "Bash(npx svelte-kit:*)", 12 + "Bash(ls:*)", 13 + "Bash(pnpm format:*)", 14 + "Bash(pnpm add:*)", 15 + "WebSearch", 16 + "WebFetch(domain:github.com)", 17 + "WebFetch(domain:flipclockjs.com)", 18 + "WebFetch(domain:codepen.io)", 19 + "WebFetch(domain:flo-bit.dev)", 20 + "Bash(pnpm install)", 21 + "Bash(pnpm install:*)", 22 + "Bash(pnpm config:*)", 23 + "Bash(lsof:*)", 24 + "Bash(pnpm dev)", 25 + "Bash(pnpm exec svelte-kit:*)", 26 + "Bash(pnpm build:*)", 27 + "Bash(pnpm remove:*)", 28 + "Bash(grep:*)", 29 + "Bash(find:*)", 30 + "Bash(npx prettier:*)", 31 + "Bash(node -e:*)", 32 + "mcp__plugin_svelte_svelte__get-documentation", 33 + "WebFetch(domain:bits-ui.com)" 34 + ] 35 + } 36 36 }
+2
src/lib/cache.ts
··· 13 13 npmx: 60 * 60 * 12, // 12 hours 14 14 profile: 60 * 60 * 24, // 24 hours 15 15 ical: 60 * 60 * 2, // 2 hours 16 + events: 60 * 60, // 1 hour 17 + rsvps: 60 * 60, // 1 hour 16 18 meta: 0 // no auto-expiry 17 19 } as const; 18 20
+4
src/lib/cards/index.ts
··· 27 27 import { StandardSiteDocumentListCardDefinition } from './content/StandardSiteDocumentListCard'; 28 28 import { StatusphereCardDefinition } from './media/StatusphereCard'; 29 29 import { EventCardDefinition } from './social/EventCard'; 30 + import { UpcomingEventsCardDefinition } from './social/UpcomingEventsCard'; 31 + import { UpcomingRsvpsCardDefinition } from './social/UpcomingRsvpsCard'; 30 32 import { VCardCardDefinition } from './social/VCardCard'; 31 33 import { DrawCardDefinition } from './visual/DrawCard'; 32 34 import { TimerCardDefinition } from './utilities/TimerCard'; ··· 83 85 StandardSiteDocumentListCardDefinition, 84 86 StatusphereCardDefinition, 85 87 EventCardDefinition, 88 + UpcomingEventsCardDefinition, 89 + UpcomingRsvpsCardDefinition, 86 90 VCardCardDefinition, 87 91 DrawCardDefinition, 88 92 TimerCardDefinition,
+260
src/lib/cards/social/UpcomingEventsCard/UpcomingEventsCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { Badge } from '@foxui/core'; 4 + import { 5 + getAdditionalUserData, 6 + getDidContext, 7 + getHandleContext, 8 + getIsMobile 9 + } from '$lib/website/context'; 10 + import type { ContentComponentProps } from '../../types'; 11 + import { CardDefinitionsByType } from '../..'; 12 + import type { EventData } from '../EventCard'; 13 + import { user } from '$lib/atproto'; 14 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 15 + import * as TID from '@atcute/tid'; 16 + 17 + let { item }: ContentComponentProps = $props(); 18 + 19 + let isMobile = getIsMobile(); 20 + let isLoaded = $state(false); 21 + const data = getAdditionalUserData(); 22 + const did = getDidContext(); 23 + const handle = getHandleContext(); 24 + 25 + type EventWithRkey = EventData & { rkey: string }; 26 + 27 + let events = $state<EventWithRkey[]>( 28 + ((data['upcomingEvents'] as { events?: EventWithRkey[] })?.events ?? []) as EventWithRkey[] 29 + ); 30 + 31 + onMount(async () => { 32 + try { 33 + const loaded = await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 34 + did, 35 + handle 36 + }); 37 + const result = loaded as { events?: EventWithRkey[] } | undefined; 38 + const freshEvents = result?.events ?? []; 39 + 40 + if (freshEvents.length > 0) { 41 + events = freshEvents; 42 + } 43 + 44 + data['upcomingEvents'] = { events }; 45 + } catch (e) { 46 + console.error('Failed to load upcoming events', e); 47 + } 48 + 49 + isLoaded = true; 50 + }); 51 + 52 + function formatDate(dateStr: string): string { 53 + const date = new Date(dateStr); 54 + return date.toLocaleDateString('en-US', { 55 + weekday: 'short', 56 + month: 'short', 57 + day: 'numeric' 58 + }); 59 + } 60 + 61 + function formatTime(dateStr: string): string { 62 + const date = new Date(dateStr); 63 + return date.toLocaleTimeString('en-US', { 64 + hour: 'numeric', 65 + minute: '2-digit' 66 + }); 67 + } 68 + 69 + function getModeLabel(mode: string): string { 70 + if (mode.includes('virtual')) return 'Virtual'; 71 + if (mode.includes('hybrid')) return 'Hybrid'; 72 + if (mode.includes('inperson')) return 'In-Person'; 73 + return 'Event'; 74 + } 75 + 76 + function getModeColor(mode: string): string { 77 + if (mode.includes('virtual')) return 'blue'; 78 + if (mode.includes('hybrid')) return 'purple'; 79 + if (mode.includes('inperson')) return 'green'; 80 + return 'gray'; 81 + } 82 + 83 + let isOwner = $derived(user.isLoggedIn && user.did === did); 84 + let isRefreshing = $state(false); 85 + 86 + async function refreshEvents() { 87 + isRefreshing = true; 88 + try { 89 + const loaded = await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 90 + did, 91 + handle 92 + }); 93 + const result = loaded as { events?: EventWithRkey[] } | undefined; 94 + const freshEvents = result?.events ?? []; 95 + events = freshEvents; 96 + data['upcomingEvents'] = { events }; 97 + } catch (e) { 98 + console.error('Failed to refresh events', e); 99 + } 100 + isRefreshing = false; 101 + } 102 + </script> 103 + 104 + <div class="flex h-full flex-col overflow-hidden p-4"> 105 + <!-- Header row --> 106 + <div class="mb-3 flex items-center justify-between"> 107 + <div class="flex items-center gap-2"> 108 + <div 109 + class="bg-base-100 border-base-300 accent:bg-accent-100/50 accent:border-accent-200 dark:border-base-800 dark:bg-base-900 flex size-8 shrink-0 items-center justify-center rounded-xl border" 110 + > 111 + <svg 112 + xmlns="http://www.w3.org/2000/svg" 113 + fill="none" 114 + viewBox="0 0 24 24" 115 + stroke-width="1.5" 116 + stroke="currentColor" 117 + class="size-4" 118 + > 119 + <path 120 + stroke-linecap="round" 121 + stroke-linejoin="round" 122 + 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" 123 + /> 124 + </svg> 125 + </div> 126 + <span class="text-base-900 dark:text-base-50 text-sm font-semibold">Events</span> 127 + </div> 128 + {#if isOwner} 129 + <div class="flex items-center gap-1"> 130 + <button 131 + onclick={refreshEvents} 132 + disabled={isRefreshing} 133 + title="Refresh events" 134 + class="bg-base-100 hover:bg-base-200 dark:bg-base-800 dark:hover:bg-base-700 accent:bg-accent-400/30 accent:hover:bg-accent-400/50 text-base-700 dark:text-base-300 z-50 flex size-7 items-center justify-center rounded-lg transition-colors disabled:opacity-50" 135 + > 136 + <svg 137 + xmlns="http://www.w3.org/2000/svg" 138 + fill="none" 139 + viewBox="0 0 24 24" 140 + stroke-width="2" 141 + stroke="currentColor" 142 + class="size-4" 143 + class:animate-spin={isRefreshing} 144 + > 145 + <path 146 + stroke-linecap="round" 147 + stroke-linejoin="round" 148 + d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.992 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182M20.016 4.657v4.992" 149 + /> 150 + </svg> 151 + </button> 152 + <a 153 + href="/{handle}/events/{TID.now()}/edit" 154 + target="_blank" 155 + title="Create new event" 156 + class="bg-base-100 hover:bg-base-200 dark:bg-base-800 dark:hover:bg-base-700 accent:bg-accent-400/30 accent:hover:bg-accent-400/50 text-base-700 dark:text-base-300 z-50 flex size-7 items-center justify-center rounded-lg transition-colors" 157 + > 158 + <svg 159 + xmlns="http://www.w3.org/2000/svg" 160 + fill="none" 161 + viewBox="0 0 24 24" 162 + stroke-width="2" 163 + stroke="currentColor" 164 + class="size-4" 165 + > 166 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 167 + </svg> 168 + </a> 169 + </div> 170 + {/if} 171 + </div> 172 + 173 + <!-- Scrollable list --> 174 + <div class="flex-1 overflow-y-auto"> 175 + {#if events.length > 0} 176 + <div class="flex flex-col gap-2"> 177 + {#each events as event (event.rkey)} 178 + <a 179 + href="https://blento.app/{did}/events/{event.rkey}" 180 + target="_blank" 181 + class="hover:bg-base-100 dark:hover:bg-base-800 accent:hover:bg-accent-400/20 flex flex-col gap-1 rounded-lg p-2 transition-colors" 182 + use:qrOverlay={{ context: { title: event.name } }} 183 + > 184 + <div class="flex items-center gap-2"> 185 + <span class="text-base-900 dark:text-base-50 line-clamp-1 flex-1 text-sm font-medium" 186 + >{event.name}</span 187 + > 188 + <Badge size="sm" color={getModeColor(event.mode)}> 189 + <span class="accent:text-base-900">{getModeLabel(event.mode)}</span> 190 + </Badge> 191 + </div> 192 + <div 193 + class="text-base-500 dark:text-base-400 accent:text-base-800 flex items-center gap-1 text-xs" 194 + > 195 + <svg 196 + xmlns="http://www.w3.org/2000/svg" 197 + fill="none" 198 + viewBox="0 0 24 24" 199 + stroke-width="1.5" 200 + stroke="currentColor" 201 + class="size-3" 202 + > 203 + <path 204 + stroke-linecap="round" 205 + stroke-linejoin="round" 206 + d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 207 + /> 208 + </svg> 209 + <span>{formatDate(event.startsAt)} at {formatTime(event.startsAt)}</span> 210 + </div> 211 + {#if event.locations && event.locations.length > 0} 212 + {@const loc = event.locations[0]?.address} 213 + {#if loc} 214 + {@const parts = [loc.locality, loc.region, loc.country].filter(Boolean)} 215 + {#if parts.length > 0} 216 + <div 217 + class="text-base-500 dark:text-base-400 accent:text-base-800 flex items-center gap-1 text-xs" 218 + > 219 + <svg 220 + xmlns="http://www.w3.org/2000/svg" 221 + fill="none" 222 + viewBox="0 0 24 24" 223 + stroke-width="1.5" 224 + stroke="currentColor" 225 + class="size-3 shrink-0" 226 + > 227 + <path 228 + stroke-linecap="round" 229 + stroke-linejoin="round" 230 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 231 + /> 232 + <path 233 + stroke-linecap="round" 234 + stroke-linejoin="round" 235 + d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" 236 + /> 237 + </svg> 238 + <span class="truncate">{parts.join(', ')}</span> 239 + </div> 240 + {/if} 241 + {/if} 242 + {/if} 243 + </a> 244 + {/each} 245 + </div> 246 + {:else if isLoaded} 247 + <div 248 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 249 + > 250 + No upcoming events 251 + </div> 252 + {:else} 253 + <div 254 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 255 + > 256 + Loading events... 257 + </div> 258 + {/if} 259 + </div> 260 + </div>
+52
src/lib/cards/social/UpcomingEventsCard/index.ts
··· 1 + import { listRecords } from '$lib/atproto'; 2 + import type { CardDefinition } from '../../types'; 3 + import UpcomingEventsCard from './UpcomingEventsCard.svelte'; 4 + import type { Did } from '@atcute/lexicons'; 5 + import type { EventData } from '../EventCard'; 6 + 7 + const EVENT_COLLECTION = 'community.lexicon.calendar.event'; 8 + 9 + export const UpcomingEventsCardDefinition = { 10 + type: 'upcomingEvents', 11 + contentComponent: UpcomingEventsCard, 12 + createNew: (card) => { 13 + card.w = 4; 14 + card.h = 4; 15 + card.mobileW = 8; 16 + card.mobileH = 6; 17 + }, 18 + minW: 2, 19 + minH: 3, 20 + 21 + loadData: async (_items, { did }) => { 22 + const records = await listRecords({ 23 + did: did as Did, 24 + collection: EVENT_COLLECTION, 25 + limit: 100 26 + }); 27 + 28 + const now = new Date(); 29 + const events: Array<EventData & { rkey: string }> = []; 30 + 31 + for (const record of records) { 32 + const event = record.value as EventData; 33 + const endsAt = event.endsAt ? new Date(event.endsAt) : null; 34 + const startsAt = new Date(event.startsAt); 35 + 36 + if ((endsAt && endsAt >= now) || (!endsAt && startsAt >= now)) { 37 + const uri = record.uri as string; 38 + const rkey = uri.split('/').pop() || ''; 39 + events.push({ ...event, rkey }); 40 + } 41 + } 42 + 43 + events.sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime()); 44 + 45 + return { events }; 46 + }, 47 + 48 + name: 'Upcoming Events', 49 + keywords: ['events', 'hosting', 'calendar', 'upcoming'], 50 + groups: ['Social'], 51 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" 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.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>` 52 + } as CardDefinition & { type: 'upcomingEvents' };
+162
src/lib/cards/social/UpcomingRsvpsCard/UpcomingRsvpsCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { Badge } from '@foxui/core'; 4 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 5 + import type { ContentComponentProps } from '../../types'; 6 + import { CardDefinitionsByType } from '../..'; 7 + import type { ResolvedRsvp } from '$lib/events/fetch-attendees'; 8 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 9 + 10 + let { item }: ContentComponentProps = $props(); 11 + 12 + let isLoaded = $state(false); 13 + const data = getAdditionalUserData(); 14 + const did = getDidContext(); 15 + const handle = getHandleContext(); 16 + 17 + let rsvps = $state<ResolvedRsvp[]>( 18 + ((data['upcomingRsvps'] as { rsvps?: ResolvedRsvp[] })?.rsvps ?? []) as ResolvedRsvp[] 19 + ); 20 + 21 + onMount(async () => { 22 + try { 23 + const loaded = await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 24 + did, 25 + handle 26 + }); 27 + const result = loaded as { rsvps?: ResolvedRsvp[] } | undefined; 28 + const freshRsvps = result?.rsvps ?? []; 29 + 30 + if (freshRsvps.length > 0) { 31 + rsvps = freshRsvps; 32 + } 33 + 34 + data['upcomingRsvps'] = { rsvps }; 35 + } catch (e) { 36 + console.error('Failed to load RSVPs', e); 37 + } 38 + 39 + isLoaded = true; 40 + }); 41 + 42 + function formatDate(dateStr: string): string { 43 + const date = new Date(dateStr); 44 + return date.toLocaleDateString('en-US', { 45 + weekday: 'short', 46 + month: 'short', 47 + day: 'numeric' 48 + }); 49 + } 50 + 51 + function formatTime(dateStr: string): string { 52 + const date = new Date(dateStr); 53 + return date.toLocaleTimeString('en-US', { 54 + hour: 'numeric', 55 + minute: '2-digit' 56 + }); 57 + } 58 + </script> 59 + 60 + <div class="flex h-full flex-col overflow-hidden p-4"> 61 + <div class="mb-3 flex items-center gap-2"> 62 + <div 63 + class="bg-base-100 border-base-300 accent:bg-accent-100/50 accent:border-accent-200 dark:border-base-800 dark:bg-base-900 flex size-8 shrink-0 items-center justify-center rounded-xl border" 64 + > 65 + <svg 66 + xmlns="http://www.w3.org/2000/svg" 67 + fill="none" 68 + viewBox="0 0 24 24" 69 + stroke-width="1.5" 70 + stroke="currentColor" 71 + class="size-4" 72 + > 73 + <path 74 + stroke-linecap="round" 75 + stroke-linejoin="round" 76 + d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 77 + /> 78 + </svg> 79 + </div> 80 + <span class="text-base-900 dark:text-base-50 text-sm font-semibold">RSVPs</span> 81 + </div> 82 + 83 + <div class="flex-1 overflow-y-auto"> 84 + {#if rsvps.length > 0} 85 + <div class="flex flex-col gap-2"> 86 + {#each rsvps as rsvp (rsvp.eventUri)} 87 + <a 88 + href="https://blento.app/{rsvp.hostDid}/events/{rsvp.rkey}" 89 + target="_blank" 90 + class="hover:bg-base-100 dark:hover:bg-base-800 accent:hover:bg-accent-400/20 flex flex-col gap-1 rounded-lg p-2 transition-colors" 91 + use:qrOverlay={{ context: { title: rsvp.event.name } }} 92 + > 93 + <div class="flex items-center gap-2"> 94 + <span class="text-base-900 dark:text-base-50 line-clamp-1 flex-1 text-sm font-medium" 95 + >{rsvp.event.name}</span 96 + > 97 + <Badge size="sm" color={rsvp.status === 'going' ? 'green' : 'amber'}> 98 + <span class="accent:text-base-900" 99 + >{rsvp.status === 'going' ? 'Going' : 'Interested'}</span 100 + > 101 + </Badge> 102 + </div> 103 + <div 104 + class="text-base-500 dark:text-base-400 accent:text-base-800 flex items-center gap-1 text-xs" 105 + > 106 + <svg 107 + xmlns="http://www.w3.org/2000/svg" 108 + fill="none" 109 + viewBox="0 0 24 24" 110 + stroke-width="1.5" 111 + stroke="currentColor" 112 + class="size-3 shrink-0" 113 + > 114 + <path 115 + stroke-linecap="round" 116 + stroke-linejoin="round" 117 + d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 118 + /> 119 + </svg> 120 + <span>{formatDate(rsvp.event.startsAt)} at {formatTime(rsvp.event.startsAt)}</span> 121 + </div> 122 + {#if rsvp.hostProfile} 123 + <div 124 + class="text-base-500 dark:text-base-400 accent:text-base-800 flex items-center gap-1 text-xs" 125 + > 126 + <svg 127 + xmlns="http://www.w3.org/2000/svg" 128 + fill="none" 129 + viewBox="0 0 24 24" 130 + stroke-width="1.5" 131 + stroke="currentColor" 132 + class="size-3 shrink-0" 133 + > 134 + <path 135 + stroke-linecap="round" 136 + stroke-linejoin="round" 137 + d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" 138 + /> 139 + </svg> 140 + <span class="truncate" 141 + >{rsvp.hostProfile.displayName || rsvp.hostProfile.handle}</span 142 + > 143 + </div> 144 + {/if} 145 + </a> 146 + {/each} 147 + </div> 148 + {:else if isLoaded} 149 + <div 150 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 151 + > 152 + No upcoming RSVPs 153 + </div> 154 + {:else} 155 + <div 156 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 157 + > 158 + Loading RSVPs... 159 + </div> 160 + {/if} 161 + </div> 162 + </div>
+41
src/lib/cards/social/UpcomingRsvpsCard/index.ts
··· 1 + import { fetchUserRsvps } from '$lib/events/fetch-attendees'; 2 + import type { CardDefinition } from '../../types'; 3 + import UpcomingRsvpsCard from './UpcomingRsvpsCard.svelte'; 4 + import type { ResolvedRsvp } from '$lib/events/fetch-attendees'; 5 + 6 + export type { ResolvedRsvp }; 7 + 8 + export const UpcomingRsvpsCardDefinition = { 9 + type: 'upcomingRsvps', 10 + contentComponent: UpcomingRsvpsCard, 11 + createNew: (card) => { 12 + card.w = 4; 13 + card.h = 4; 14 + card.mobileW = 8; 15 + card.mobileH = 6; 16 + }, 17 + minW: 2, 18 + minH: 3, 19 + 20 + loadData: async (_items, { did, cache }) => { 21 + const rsvps = await fetchUserRsvps(did, cache); 22 + 23 + const now = new Date(); 24 + const upcoming = rsvps.filter((r) => { 25 + const endsAt = r.event.endsAt ? new Date(r.event.endsAt) : null; 26 + const startsAt = new Date(r.event.startsAt); 27 + return (endsAt && endsAt >= now) || (!endsAt && startsAt >= now); 28 + }); 29 + 30 + upcoming.sort( 31 + (a, b) => new Date(a.event.startsAt).getTime() - new Date(b.event.startsAt).getTime() 32 + ); 33 + 34 + return { rsvps: upcoming }; 35 + }, 36 + 37 + name: 'Upcoming RSVPs', 38 + keywords: ['rsvp', 'attending', 'going', 'interested', 'events'], 39 + groups: ['Social'], 40 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>` 41 + } as CardDefinition & { type: 'upcomingRsvps' };
+73 -1
src/lib/events/fetch-attendees.ts
··· 1 - import { getBlentoOrBskyProfile, parseUri } from '$lib/atproto/methods'; 1 + import { getBlentoOrBskyProfile, getRecord, listRecords, parseUri } from '$lib/atproto/methods'; 2 2 import type { CacheService, CachedProfile } from '$lib/cache'; 3 + import type { EventData } from '$lib/cards/social/EventCard'; 3 4 import type { Did } from '@atcute/lexicons'; 4 5 5 6 export type RsvpStatus = 'going' | 'interested'; 7 + 8 + export interface ResolvedRsvp { 9 + event: EventData; 10 + rkey: string; 11 + hostDid: string; 12 + hostProfile: CachedProfile | null; 13 + status: 'going' | 'interested'; 14 + eventUri: string; 15 + } 6 16 7 17 /** 8 18 * Fetch raw RSVP data for an event from Microcosm Constellation backlinks. ··· 106 116 const handle = profile?.handle; 107 117 return handle ? `https://bsky.app/profile/${handle}` : `https://bsky.app/profile/${did}`; 108 118 } 119 + 120 + interface RsvpRecord { 121 + $type: string; 122 + status: string; 123 + subject: { uri: string; cid?: string }; 124 + createdAt: string; 125 + } 126 + 127 + /** 128 + * Fetch a user's RSVPs (going/interested) and resolve each referenced event + host profile. 129 + */ 130 + export async function fetchUserRsvps( 131 + did: string, 132 + cache?: CacheService | null 133 + ): Promise<ResolvedRsvp[]> { 134 + const rsvpRecords = await listRecords({ 135 + did: did as Did, 136 + collection: 'community.lexicon.calendar.rsvp', 137 + limit: 100 138 + }); 139 + 140 + const activeRsvps = rsvpRecords.filter((r) => { 141 + const rsvp = r.value as unknown as RsvpRecord; 142 + return rsvp.status?.endsWith('#going') || rsvp.status?.endsWith('#interested'); 143 + }); 144 + 145 + const results = await Promise.all( 146 + activeRsvps.map(async (r) => { 147 + const rsvp = r.value as unknown as RsvpRecord; 148 + const parsed = parseUri(rsvp.subject.uri); 149 + if (!parsed?.rkey || !parsed?.repo) return null; 150 + 151 + try { 152 + const [record, hostProfile] = await Promise.all([ 153 + getRecord({ 154 + did: parsed.repo as Did, 155 + collection: 'community.lexicon.calendar.event', 156 + rkey: parsed.rkey 157 + }), 158 + resolveProfile(parsed.repo, cache).catch(() => null) 159 + ]); 160 + 161 + if (!record?.value) return null; 162 + 163 + return { 164 + event: record.value as EventData, 165 + rkey: parsed.rkey, 166 + hostDid: parsed.repo, 167 + hostProfile, 168 + status: (rsvp.status?.endsWith('#going') ? 'going' : 'interested') as 169 + | 'going' 170 + | 'interested', 171 + eventUri: rsvp.subject.uri 172 + }; 173 + } catch { 174 + return null; 175 + } 176 + }) 177 + ); 178 + 179 + return results.filter((r) => r !== null); 180 + }
+1 -1
src/lib/website/Account.svelte
··· 20 20 <Popover sideOffset={8} bind:open={settingsPopoverOpen} class="bg-base-100 dark:bg-base-900"> 21 21 {#snippet child({ props })} 22 22 <button {...props}> 23 - <Avatar src={user.profile?.avatar} alt="" class="size-15 rounded-full" /> 23 + <Avatar src={user.profile?.avatar} alt="" class="size-15 cursor-pointer rounded-full" /> 24 24 </button> 25 25 {/snippet} 26 26
+18 -1
src/routes/[[actor=actor]]/events/+page.server.ts
··· 15 15 } 16 16 17 17 try { 18 + // Try cache first 19 + if (cache) { 20 + const cached = await cache.getJSON<{ 21 + events: (EventData & { rkey: string })[]; 22 + did: string; 23 + hostProfile: CachedProfile | null; 24 + }>('events', did); 25 + if (cached) return cached; 26 + } 27 + 18 28 const [records, hostProfile] = await Promise.all([ 19 29 listRecords({ 20 30 did: did as Did, ··· 42 52 rkey: r.uri.split('/').pop() as string 43 53 })); 44 54 45 - return { 55 + const result = { 46 56 events, 47 57 did, 48 58 hostProfile: hostProfile ?? null 49 59 }; 60 + 61 + // Cache the result 62 + if (cache) { 63 + await cache.putJSON('events', did, result).catch(() => {}); 64 + } 65 + 66 + return result; 50 67 } catch (e) { 51 68 if (e && typeof e === 'object' && 'status' in e) throw e; 52 69 throw error(404, 'Events not found');
+55 -16
src/routes/[[actor=actor]]/events/+page.svelte
··· 2 2 import type { EventData } from '$lib/cards/social/EventCard'; 3 3 import { getCDNImageBlobUrl } from '$lib/atproto'; 4 4 import { user } from '$lib/atproto/auth.svelte'; 5 - import { Avatar as FoxAvatar, Badge, Button } from '@foxui/core'; 5 + import { Avatar as FoxAvatar, Badge, Button, toast } from '@foxui/core'; 6 + import { page } from '$app/state'; 6 7 import Avatar from 'svelte-boring-avatars'; 7 8 import * as TID from '@atcute/tid'; 8 9 import { goto } from '$app/navigation'; ··· 78 79 } 79 80 80 81 let isOwner = $derived(user.isLoggedIn && user.did === did); 82 + 83 + let showPast: boolean = $state(false); 84 + let now = $derived(new Date()); 85 + let filteredEvents = $derived( 86 + events.filter((e) => { 87 + const endOrStart = e.endsAt || e.startsAt; 88 + const eventDate = new Date(endOrStart); 89 + return showPast ? eventDate < now : eventDate >= now; 90 + }) 91 + ); 81 92 </script> 82 93 83 94 <svelte:head> ··· 96 107 <div class="mb-8 flex items-start justify-between"> 97 108 <div> 98 109 <h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold sm:text-3xl"> 99 - Upcoming events 110 + {showPast ? 'Past' : 'Upcoming'} events 100 111 </h1> 101 112 <div class="mt-4 flex items-center gap-2"> 102 113 <span class="text-base-500 dark:text-base-400 text-sm">Hosted by</span> ··· 111 122 </a> 112 123 </div> 113 124 </div> 114 - {#if isOwner} 125 + <div class="flex flex-col items-end gap-2"> 126 + {#if isOwner} 127 + <Button 128 + variant="primary" 129 + onclick={() => { 130 + const rkey = TID.now(); 131 + const handle = 132 + user.profile?.handle && user.profile.handle !== 'handle.invalid' 133 + ? user.profile.handle 134 + : user.did; 135 + goto(`/${handle}/events/${rkey}/edit`); 136 + }}>New event</Button 137 + > 138 + {/if} 115 139 <Button 116 - variant="primary" 117 - onclick={() => { 118 - const rkey = TID.now(); 119 - const handle = 120 - user.profile?.handle && user.profile.handle !== 'handle.invalid' 121 - ? user.profile.handle 122 - : user.did; 123 - goto(`/${handle}/events/${rkey}/edit`); 124 - }}>New event</Button 140 + variant="secondary" 141 + onclick={async () => { 142 + const calendarUrl = `${page.url.origin}${page.url.pathname.replace(/\/$/, '')}/calendar`; 143 + await navigator.clipboard.writeText(calendarUrl); 144 + toast.success('Subscription link copied to clipboard'); 145 + }}>Subscribe</Button 125 146 > 126 - {/if} 147 + </div> 148 + </div> 149 + 150 + <!-- Toggle --> 151 + <div class="mb-6 flex gap-1"> 152 + <button 153 + class="rounded-xl px-3 py-1.5 text-sm font-medium transition-colors {!showPast 154 + ? 'bg-base-200 dark:bg-base-800 text-base-900 dark:text-base-50' 155 + : 'text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 cursor-pointer'}" 156 + onclick={() => (showPast = false)}>Upcoming</button 157 + > 158 + <button 159 + class="rounded-xl px-3 py-1.5 text-sm font-medium transition-colors {showPast 160 + ? 'bg-base-200 dark:bg-base-800 text-base-900 dark:text-base-50' 161 + : 'text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 cursor-pointer'}" 162 + onclick={() => (showPast = true)}>Past</button 163 + > 127 164 </div> 128 165 129 - {#if events.length === 0} 130 - <p class="text-base-500 dark:text-base-400 py-12 text-center">No events found.</p> 166 + {#if filteredEvents.length === 0} 167 + <p class="text-base-500 dark:text-base-400 py-12 text-center"> 168 + No {showPast ? 'past' : 'upcoming'} events. 169 + </p> 131 170 {:else} 132 171 <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> 133 - {#each events as event (event.rkey)} 172 + {#each filteredEvents as event (event.rkey)} 134 173 {@const thumbnail = getThumbnail(event)} 135 174 {@const location = getLocationString(event.locations)} 136 175 {@const rkey = event.rkey}
+62 -63
src/routes/[[actor=actor]]/events/[rkey]/+page.svelte
··· 217 217 218 218 <!-- Two-column layout: image left, details right --> 219 219 <div 220 - 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]" 220 + class="grid grid-cols-1 gap-8 md:grid-cols-[14rem_1fr] md:grid-rows-[auto_1fr] md:gap-x-10 md:gap-y-6 lg:grid-cols-[16rem_1fr]" 221 221 > 222 222 <!-- Thumbnail image (left column) --> 223 223 {#if !isBannerOnly} ··· 245 245 {/if} 246 246 247 247 <!-- Right column: event details --> 248 - <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> 248 + <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-2 md:row-start-1"> 249 249 <div class="mb-2 flex items-start justify-between gap-4"> 250 250 <h1 class="text-base-900 dark:text-base-50 text-4xl leading-tight font-bold sm:text-5xl"> 251 251 {eventData.name} ··· 337 337 > 338 338 About 339 339 </p> 340 - <p class="text-base-700 dark:text-base-300 leading-relaxed"> 340 + <p class="text-base-700 dark:text-base-300 leading-relaxed wrap-break-word"> 341 341 {@html descriptionHtml} 342 342 </p> 343 343 </div> 344 344 {/if} 345 345 </div> 346 346 347 - <!-- Hosted By --> 348 - <div class="order-3 md:order-0 md:col-start-1"> 349 - <p 350 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 351 - > 352 - Hosted By 353 - </p> 354 - <a 355 - href={hostUrl} 356 - target={hostProfile?.hasBlento ? undefined : '_blank'} 357 - rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 358 - class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium hover:underline" 359 - > 360 - <FoxAvatar 361 - src={hostProfile?.avatar} 362 - alt={hostProfile?.displayName || hostProfile?.handle || did} 363 - class="size-8 shrink-0" 364 - /> 365 - <span class="truncate text-sm"> 366 - {hostProfile?.displayName || hostProfile?.handle || did} 367 - </span> 368 - </a> 369 - </div> 370 - 371 - {#if eventData.uris && eventData.uris.length > 0} 372 - <!-- Links --> 373 - <div class="order-5 md:order-0 md:col-start-1"> 347 + <!-- Left column: sidebar info --> 348 + <div class="order-3 space-y-6 md:order-0 md:col-start-1"> 349 + <!-- Hosted By --> 350 + <div> 374 351 <p 375 - class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase" 352 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 376 353 > 377 - Links 354 + Hosted By 378 355 </p> 379 - <div class="space-y-3"> 380 - {#each eventData.uris as link (link.name + link.uri)} 381 - <a 382 - href={link.uri} 383 - target="_blank" 384 - rel="noopener noreferrer" 385 - class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex items-center gap-1.5 text-sm transition-colors" 386 - > 387 - <svg 388 - xmlns="http://www.w3.org/2000/svg" 389 - fill="none" 390 - viewBox="0 0 24 24" 391 - stroke-width="1.5" 392 - stroke="currentColor" 393 - class="size-3.5 shrink-0" 356 + <a 357 + href={hostUrl} 358 + target={hostProfile?.hasBlento ? undefined : '_blank'} 359 + rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 360 + class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium hover:underline" 361 + > 362 + <FoxAvatar 363 + src={hostProfile?.avatar} 364 + alt={hostProfile?.displayName || hostProfile?.handle || did} 365 + class="size-8 shrink-0" 366 + /> 367 + <span class="truncate text-sm"> 368 + {hostProfile?.displayName || hostProfile?.handle || did} 369 + </span> 370 + </a> 371 + </div> 372 + 373 + {#if eventData.uris && eventData.uris.length > 0} 374 + <!-- Links --> 375 + <div> 376 + <p 377 + class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase" 378 + > 379 + Links 380 + </p> 381 + <div class="space-y-3"> 382 + {#each eventData.uris as link (link.name + link.uri)} 383 + <a 384 + href={link.uri} 385 + target="_blank" 386 + rel="noopener noreferrer" 387 + class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex items-center gap-1.5 text-sm transition-colors" 394 388 > 395 - <path 396 - stroke-linecap="round" 397 - stroke-linejoin="round" 398 - 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" 399 - /> 400 - </svg> 401 - <span class="truncate">{link.name || link.uri.replace(/^https?:\/\//, '')}</span> 402 - </a> 403 - {/each} 389 + <svg 390 + xmlns="http://www.w3.org/2000/svg" 391 + fill="none" 392 + viewBox="0 0 24 24" 393 + stroke-width="1.5" 394 + stroke="currentColor" 395 + class="size-3.5 shrink-0" 396 + > 397 + <path 398 + stroke-linecap="round" 399 + stroke-linejoin="round" 400 + 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" 401 + /> 402 + </svg> 403 + <span class="truncate">{link.name || link.uri.replace(/^https?:\/\//, '')}</span> 404 + </a> 405 + {/each} 406 + </div> 404 407 </div> 405 - </div> 406 - {/if} 408 + {/if} 407 409 408 - <!-- Add to Calendar --> 409 - <div class="order-5 md:order-0 md:col-start-1"> 410 + <!-- Add to Calendar --> 410 411 <button 411 412 onclick={downloadIcs} 412 413 class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex cursor-pointer items-center gap-2 text-sm font-medium transition-colors" ··· 427 428 </svg> 428 429 Add to Calendar 429 430 </button> 430 - </div> 431 431 432 - <!-- Attendees --> 433 - <div class="order-5 md:order-0 md:col-start-1"> 432 + <!-- Attendees --> 434 433 <EventAttendees bind:this={attendeesRef} {eventUri} /> 435 434 </div> 436 435
+6 -1
src/routes/[[actor=actor]]/events/[rkey]/EventAttendees.svelte
··· 166 166 </div> 167 167 {/if} 168 168 169 - <Modal bind:open={modalOpen} closeButton onOpenAutoFocus={(e) => e.preventDefault()} class="p-0"> 169 + <Modal 170 + bind:open={modalOpen} 171 + closeButton 172 + onOpenAutoFocus={(e: Event) => e.preventDefault()} 173 + class="p-0" 174 + > 170 175 <p class="text-base-900 dark:text-base-50 px-4 pt-4 text-lg font-semibold"> 171 176 {modalTitle} 172 177 <span class="text-base-500 dark:text-base-400 text-sm font-normal">
+12
src/routes/[[actor=actor]]/events/[rkey]/EventRsvp.svelte
··· 94 94 rsvpStatus = status; 95 95 rsvpRkey = key; 96 96 onrsvp?.(status); 97 + refreshRsvpCache(); 97 98 } 98 99 } catch (e) { 99 100 console.error('Failed to submit RSVP:', e); ··· 113 114 rsvpStatus = null; 114 115 rsvpRkey = null; 115 116 oncancel?.(); 117 + refreshRsvpCache(); 116 118 } catch (e) { 117 119 console.error('Failed to cancel RSVP:', e); 118 120 } finally { 119 121 rsvpSubmitting = false; 122 + } 123 + } 124 + 125 + function refreshRsvpCache() { 126 + const handle = 127 + user.profile?.handle && user.profile.handle !== 'handle.invalid' 128 + ? user.profile.handle 129 + : user.did; 130 + if (handle) { 131 + fetch(`/${handle}/rsvp/api/refresh`).catch(() => {}); 120 132 } 121 133 } 122 134 </script>
+5 -12
src/routes/[[actor=actor]]/events/[rkey]/edit/+page.svelte
··· 412 412 const s = new Date(startsAt); 413 413 const e = new Date(endsAt); 414 414 if (s >= e) { 415 + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary local, not reactive state 415 416 const adjusted = new Date(s); 416 417 adjusted.setHours(adjusted.getHours() + 1); 417 418 endsAt = isoToDatetimeLocal(adjusted.toISOString()); ··· 564 565 user.profile?.handle && user.profile.handle !== 'handle.invalid' 565 566 ? user.profile.handle 566 567 : user.did; 568 + fetch(`/${handle}/events/api/refresh`).catch(() => {}); 567 569 goto(`/${handle}/events/${rkey}`); 568 570 } else { 569 571 error = `Failed to ${isNew ? 'create' : 'save'} event. Please try again.`; ··· 592 594 user.profile?.handle && user.profile.handle !== 'handle.invalid' 593 595 ? user.profile.handle 594 596 : user.did; 597 + fetch(`/${handle}/events/api/refresh`).catch(() => {}); 595 598 goto(`/${handle}/events`); 596 599 } catch (e) { 597 600 console.error('Failed to delete event:', e); ··· 1165 1168 > 1166 1169 Cancel 1167 1170 </Button> 1168 - <Button 1169 - size="sm" 1170 - onclick={handleDelete} 1171 - disabled={deleting} 1172 - variant="red" 1173 - > 1171 + <Button size="sm" onclick={handleDelete} disabled={deleting} variant="red"> 1174 1172 {deleting ? 'Deleting...' : 'Delete'} 1175 1173 </Button> 1176 1174 </div> 1177 1175 {:else} 1178 - <Button 1179 - variant="red" 1180 - onclick={() => (showDeleteConfirm = true)} 1181 - > 1182 - Delete event 1183 - </Button> 1176 + <Button variant="red" onclick={() => (showDeleteConfirm = true)}>Delete event</Button> 1184 1177 {/if} 1185 1178 </div> 1186 1179 {/if}
+47
src/routes/[[actor=actor]]/events/api/refresh/+server.ts
··· 1 + import { createCache } from '$lib/cache'; 2 + import { error, json } from '@sveltejs/kit'; 3 + import { getActor } from '$lib/actor'; 4 + import { listRecords } from '$lib/atproto/methods.js'; 5 + import type { EventData } from '$lib/cards/social/EventCard'; 6 + import type { Did } from '@atcute/lexicons'; 7 + 8 + export async function GET({ params, platform, request }) { 9 + const cache = createCache(platform); 10 + if (!cache) return json('no cache'); 11 + 12 + const did = await getActor({ request, paramActor: params.actor, platform, blockBoth: false }); 13 + 14 + if (!did) { 15 + throw error(404, 'Not found'); 16 + } 17 + 18 + // Delete stale caches 19 + await Promise.all([cache.delete('events', did), cache.delete('ical', `${did}:calendar`)]).catch( 20 + () => {} 21 + ); 22 + 23 + // Re-fetch and cache 24 + const [records, hostProfile] = await Promise.all([ 25 + listRecords({ 26 + did: did as Did, 27 + collection: 'community.lexicon.calendar.event', 28 + limit: 100 29 + }), 30 + cache.getProfile(did as Did).catch(() => null) 31 + ]); 32 + 33 + const events = records.map((r) => ({ 34 + ...(r.value as EventData), 35 + rkey: r.uri.split('/').pop() as string 36 + })); 37 + 38 + const result = { 39 + events, 40 + did, 41 + hostProfile: hostProfile ?? null 42 + }; 43 + 44 + await cache.putJSON('events', did, result).catch(() => {}); 45 + 46 + return json(result); 47 + }
+1 -1
src/routes/[[actor=actor]]/events/calendar/+server.ts
··· 4 4 import { createCache } from '$lib/cache'; 5 5 import type { Did } from '@atcute/lexicons'; 6 6 import { getActor } from '$lib/actor'; 7 - import { generateICalFeed, type ICalEvent } from '$lib/ical'; 7 + import { generateICalFeed, type ICalAttendee, type ICalEvent } from '$lib/ical'; 8 8 import { fetchEventRsvps, getProfileUrl, resolveProfile } from '$lib/events/fetch-attendees'; 9 9 10 10 export async function GET({ params, platform, request }) {
-135
src/routes/[[actor=actor]]/events/rsvp-calendar/+server.ts
··· 1 - import { error } from '@sveltejs/kit'; 2 - import type { EventData } from '$lib/cards/social/EventCard'; 3 - import { getCDNImageBlobUrl, getRecord, listRecords, parseUri } from '$lib/atproto/methods.js'; 4 - import { createCache } from '$lib/cache'; 5 - import type { Did } from '@atcute/lexicons'; 6 - import { getActor } from '$lib/actor'; 7 - import { generateICalFeed, type ICalAttendee, type ICalEvent } from '$lib/ical'; 8 - import { fetchEventRsvps, getProfileUrl, resolveProfile } from '$lib/events/fetch-attendees'; 9 - 10 - interface RsvpRecord { 11 - $type: string; 12 - status: string; 13 - subject: { uri: string; cid?: string }; 14 - createdAt: string; 15 - } 16 - 17 - export async function GET({ params, platform, request }) { 18 - const cache = createCache(platform); 19 - 20 - const did = await getActor({ request, paramActor: params.actor, platform }); 21 - 22 - if (!did) { 23 - throw error(404, 'Not found'); 24 - } 25 - 26 - try { 27 - // Check cache first 28 - const cacheKey = `${did}:rsvp-calendar`; 29 - if (cache) { 30 - const cached = await cache.get('ical', cacheKey); 31 - if (cached) { 32 - return new Response(cached, { 33 - headers: { 34 - 'Content-Type': 'text/calendar; charset=utf-8', 35 - 'Cache-Control': 'public, max-age=3600' 36 - } 37 - }); 38 - } 39 - } 40 - 41 - const [rsvpRecords, hostProfile] = await Promise.all([ 42 - listRecords({ 43 - did: did as Did, 44 - collection: 'community.lexicon.calendar.rsvp', 45 - limit: 100 46 - }), 47 - resolveProfile(did, cache) 48 - ]); 49 - 50 - // Filter to only going and interested RSVPs 51 - const activeRsvps = rsvpRecords.filter((r) => { 52 - const rsvp = r.value as unknown as RsvpRecord; 53 - return rsvp.status?.endsWith('#going') || rsvp.status?.endsWith('#interested'); 54 - }); 55 - 56 - // Fetch each referenced event in parallel 57 - const eventResults = await Promise.all( 58 - activeRsvps.map(async (r) => { 59 - const rsvp = r.value as unknown as RsvpRecord; 60 - const parsed = parseUri(rsvp.subject.uri); 61 - if (!parsed?.rkey || !parsed?.repo) return null; 62 - 63 - try { 64 - const [record, organizerProfile] = await Promise.all([ 65 - getRecord({ 66 - did: parsed.repo as Did, 67 - collection: 'community.lexicon.calendar.event', 68 - rkey: parsed.rkey 69 - }), 70 - resolveProfile(parsed.repo, cache).catch(() => null) 71 - ]); 72 - if (!record?.value) return null; 73 - const eventData = record.value as EventData; 74 - const actor = organizerProfile?.handle || parsed.repo; 75 - const thumbnail = eventData.media?.find((m) => m.role === 'thumbnail'); 76 - const imageUrl = thumbnail?.content 77 - ? getCDNImageBlobUrl({ 78 - did: parsed.repo, 79 - blob: thumbnail.content, 80 - type: 'jpeg' 81 - }) 82 - : undefined; 83 - 84 - // Fetch RSVPs and resolve handles 85 - const rsvpMap = await fetchEventRsvps(rsvp.subject.uri).catch( 86 - () => new Map<string, 'going' | 'interested'>() 87 - ); 88 - const attendees: ICalAttendee[] = []; 89 - await Promise.all( 90 - Array.from(rsvpMap.entries()).map(async ([attendeeDid, status]) => { 91 - const profile = await resolveProfile(attendeeDid, cache).catch(() => null); 92 - attendees.push({ 93 - name: profile?.handle || attendeeDid, 94 - status, 95 - url: getProfileUrl(attendeeDid, profile) 96 - }); 97 - }) 98 - ); 99 - 100 - return { 101 - eventData, 102 - uid: rsvp.subject.uri, 103 - url: `https://blento.app/${actor}/events/${parsed.rkey}`, 104 - organizer: actor, 105 - imageUrl, 106 - attendees 107 - } satisfies ICalEvent; 108 - } catch { 109 - return null; 110 - } 111 - }) 112 - ); 113 - 114 - const events: ICalEvent[] = eventResults.filter((e) => e !== null); 115 - 116 - const actor = hostProfile?.handle || did; 117 - const calendarName = `${hostProfile?.displayName || actor}'s RSVP Events`; 118 - const ical = generateICalFeed(events, calendarName); 119 - 120 - // Store in cache 121 - if (cache) { 122 - await cache.put('ical', cacheKey, ical).catch(() => {}); 123 - } 124 - 125 - return new Response(ical, { 126 - headers: { 127 - 'Content-Type': 'text/calendar; charset=utf-8', 128 - 'Cache-Control': 'public, max-age=3600' 129 - } 130 - }); 131 - } catch (e) { 132 - if (e && typeof e === 'object' && 'status' in e) throw e; 133 - throw error(500, 'Failed to generate calendar'); 134 - } 135 - }
+28
src/routes/[[actor=actor]]/rsvp/+layout.server.ts
··· 1 + import { getRecord } from '$lib/atproto/methods.js'; 2 + import type { Did } from '@atcute/lexicons'; 3 + import { getActor } from '$lib/actor.js'; 4 + 5 + export async function load({ params, platform, request }) { 6 + const did = await getActor({ request, paramActor: params.actor, platform }); 7 + 8 + if (!did) return { accentColor: undefined, baseColor: undefined }; 9 + 10 + try { 11 + const publication = await getRecord({ 12 + did: did as Did, 13 + collection: 'site.standard.publication', 14 + rkey: 'blento.self' 15 + }); 16 + 17 + const preferences = publication?.value?.preferences as 18 + | { accentColor?: string; baseColor?: string } 19 + | undefined; 20 + 21 + return { 22 + accentColor: preferences?.accentColor, 23 + baseColor: preferences?.baseColor 24 + }; 25 + } catch { 26 + return { accentColor: undefined, baseColor: undefined }; 27 + } 28 + }
+9
src/routes/[[actor=actor]]/rsvp/+layout.svelte
··· 1 + <script lang="ts"> 2 + import ThemeScript from '$lib/website/ThemeScript.svelte'; 3 + 4 + let { data, children } = $props(); 5 + </script> 6 + 7 + <ThemeScript accentColor={data.accentColor} baseColor={data.baseColor} /> 8 + 9 + {@render children()}
+48
src/routes/[[actor=actor]]/rsvp/+page.server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import { createCache } from '$lib/cache'; 3 + import type { CachedProfile } from '$lib/cache'; 4 + import { getActor } from '$lib/actor.js'; 5 + import { fetchUserRsvps, resolveProfile, type ResolvedRsvp } from '$lib/events/fetch-attendees'; 6 + 7 + export async function load({ params, platform, request }) { 8 + const cache = createCache(platform); 9 + 10 + const did = await getActor({ request, paramActor: params.actor, platform }); 11 + 12 + if (!did) { 13 + throw error(404, 'RSVPs not found'); 14 + } 15 + 16 + try { 17 + // Try cache first 18 + if (cache) { 19 + const cached = await cache.getJSON<{ 20 + rsvps: ResolvedRsvp[]; 21 + did: string; 22 + userProfile: CachedProfile | null; 23 + }>('rsvps', did); 24 + if (cached) return cached; 25 + } 26 + 27 + const [rsvps, userProfile] = await Promise.all([ 28 + fetchUserRsvps(did, cache), 29 + resolveProfile(did, cache) 30 + ]); 31 + 32 + const result = { 33 + rsvps, 34 + did, 35 + userProfile: userProfile ?? null 36 + }; 37 + 38 + // Cache the result 39 + if (cache) { 40 + await cache.putJSON('rsvps', did, result).catch(() => {}); 41 + } 42 + 43 + return result; 44 + } catch (e) { 45 + if (e && typeof e === 'object' && 'status' in e) throw e; 46 + throw error(404, 'RSVPs not found'); 47 + } 48 + }
+200
src/routes/[[actor=actor]]/rsvp/+page.svelte
··· 1 + <script lang="ts"> 2 + import type { EventData } from '$lib/cards/social/EventCard'; 3 + import { getCDNImageBlobUrl } from '$lib/atproto'; 4 + import { Avatar as FoxAvatar, Badge, Button, toast } from '@foxui/core'; 5 + import { page } from '$app/state'; 6 + import Avatar from 'svelte-boring-avatars'; 7 + import type { CachedProfile } from '$lib/cache'; 8 + 9 + let { data } = $props(); 10 + 11 + let rsvps: Array<{ 12 + event: EventData; 13 + rkey: string; 14 + hostDid: string; 15 + hostProfile: CachedProfile | null; 16 + status: string; 17 + eventUri: string; 18 + }> = $derived(data.rsvps); 19 + let did: string = $derived(data.did); 20 + let userProfile = $derived(data.userProfile); 21 + 22 + let userName = $derived(userProfile?.displayName || userProfile?.handle || did); 23 + 24 + function formatDate(dateStr: string): string { 25 + const date = new Date(dateStr); 26 + const options: Intl.DateTimeFormatOptions = { 27 + weekday: 'short', 28 + month: 'short', 29 + day: 'numeric' 30 + }; 31 + if (date.getFullYear() !== new Date().getFullYear()) { 32 + options.year = 'numeric'; 33 + } 34 + return date.toLocaleDateString('en-US', options); 35 + } 36 + 37 + function formatTime(dateStr: string): string { 38 + return new Date(dateStr).toLocaleTimeString('en-US', { 39 + hour: 'numeric', 40 + minute: '2-digit' 41 + }); 42 + } 43 + 44 + function getModeLabel(mode: string): string { 45 + if (mode.includes('virtual')) return 'Virtual'; 46 + if (mode.includes('hybrid')) return 'Hybrid'; 47 + if (mode.includes('inperson')) return 'In-Person'; 48 + return 'Event'; 49 + } 50 + 51 + function getModeColor(mode: string): 'cyan' | 'purple' | 'amber' | 'secondary' { 52 + if (mode.includes('virtual')) return 'cyan'; 53 + if (mode.includes('hybrid')) return 'purple'; 54 + if (mode.includes('inperson')) return 'amber'; 55 + return 'secondary'; 56 + } 57 + 58 + function getThumbnail(event: EventData, hostDid: string): { url: string; alt: string } | null { 59 + if (!event.media || event.media.length === 0) return null; 60 + const media = event.media.find((m) => m.role === 'thumbnail'); 61 + if (!media?.content) return null; 62 + const url = getCDNImageBlobUrl({ did: hostDid, blob: media.content, type: 'jpeg' }); 63 + if (!url) return null; 64 + return { url, alt: media.alt || event.name }; 65 + } 66 + 67 + function getStatusLabel(status: string): string { 68 + return status === 'going' ? 'Going' : 'Interested'; 69 + } 70 + 71 + function getStatusColor(status: string): 'green' | 'blue' { 72 + return status === 'going' ? 'green' : 'blue'; 73 + } 74 + 75 + let showPast: boolean = $state(false); 76 + let now = $derived(new Date()); 77 + let filteredRsvps = $derived( 78 + rsvps.filter((r) => { 79 + const endOrStart = r.event.endsAt || r.event.startsAt; 80 + const eventDate = new Date(endOrStart); 81 + return showPast ? eventDate < now : eventDate >= now; 82 + }) 83 + ); 84 + </script> 85 + 86 + <svelte:head> 87 + <title>{userName} - RSVPs</title> 88 + <meta name="description" content="Events {userName} is attending" /> 89 + <meta property="og:title" content="{userName} - RSVPs" /> 90 + <meta property="og:description" content="Events {userName} is attending" /> 91 + <meta name="twitter:card" content="summary" /> 92 + <meta name="twitter:title" content="{userName} - RSVPs" /> 93 + <meta name="twitter:description" content="Events {userName} is attending" /> 94 + </svelte:head> 95 + 96 + <div class="min-h-screen px-6 py-12 sm:py-12"> 97 + <div class="mx-auto max-w-3xl"> 98 + <!-- Header --> 99 + <div class="mb-8 flex items-start justify-between"> 100 + <div> 101 + <h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold sm:text-3xl"> 102 + {showPast ? 'Past' : 'Upcoming'} RSVPs 103 + </h1> 104 + <div class="mt-4 flex items-center gap-2"> 105 + <FoxAvatar src={userProfile?.avatar} alt={userName} class="size-5 shrink-0" /> 106 + <span class="text-base-900 dark:text-base-100 text-sm font-medium">{userName}</span> 107 + </div> 108 + </div> 109 + <Button 110 + variant="secondary" 111 + onclick={async () => { 112 + const calendarUrl = `${page.url.origin}${page.url.pathname.replace(/\/$/, '')}/calendar`; 113 + await navigator.clipboard.writeText(calendarUrl); 114 + toast.success('Subscription link copied to clipboard'); 115 + }}>Subscribe</Button 116 + > 117 + </div> 118 + 119 + <!-- Toggle --> 120 + <div class="mb-6 flex gap-1"> 121 + <button 122 + class="rounded-xl px-3 py-1.5 text-sm font-medium transition-colors {!showPast 123 + ? 'bg-base-200 dark:bg-base-800 text-base-900 dark:text-base-50' 124 + : 'text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 cursor-pointer'}" 125 + onclick={() => (showPast = false)}>Upcoming</button 126 + > 127 + <button 128 + class="rounded-xl px-3 py-1.5 text-sm font-medium transition-colors {showPast 129 + ? 'bg-base-200 dark:bg-base-800 text-base-900 dark:text-base-50' 130 + : 'text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 cursor-pointer'}" 131 + onclick={() => (showPast = true)}>Past</button 132 + > 133 + </div> 134 + 135 + {#if filteredRsvps.length === 0} 136 + <p class="text-base-500 dark:text-base-400 py-12 text-center"> 137 + No {showPast ? 'past' : 'upcoming'} RSVPs. 138 + </p> 139 + {:else} 140 + <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> 141 + {#each filteredRsvps as rsvp (rsvp.eventUri)} 142 + {@const thumbnail = getThumbnail(rsvp.event, rsvp.hostDid)} 143 + {@const hostHandle = rsvp.hostProfile?.handle || rsvp.hostDid} 144 + <a 145 + href="/{hostHandle}/events/{rsvp.rkey}" 146 + class="border-base-200 dark:border-base-800 hover:border-base-300 dark:hover:border-base-700 group bg-base-100 dark:bg-base-950 block overflow-hidden rounded-2xl border transition-colors" 147 + > 148 + <!-- Thumbnail --> 149 + <div class="p-4"> 150 + {#if thumbnail} 151 + <img 152 + src={thumbnail.url} 153 + alt={thumbnail.alt} 154 + class="aspect-square w-full rounded-2xl object-cover" 155 + /> 156 + {:else} 157 + <div 158 + class="bg-base-100 dark:bg-base-900 aspect-square w-full overflow-hidden rounded-2xl [&>svg]:h-full [&>svg]:w-full" 159 + > 160 + <Avatar 161 + size={400} 162 + name={rsvp.rkey} 163 + variant="marble" 164 + colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 165 + square 166 + /> 167 + </div> 168 + {/if} 169 + </div> 170 + 171 + <!-- Content --> 172 + <div class="p-4"> 173 + <h2 174 + class="text-base-900 dark:text-base-50 group-hover:text-base-700 dark:group-hover:text-base-200 mb-1 leading-snug font-semibold" 175 + > 176 + {rsvp.event.name} 177 + </h2> 178 + 179 + <p class="text-base-500 dark:text-base-400 mb-2 text-sm"> 180 + {formatDate(rsvp.event.startsAt)} &middot; {formatTime(rsvp.event.startsAt)} 181 + </p> 182 + 183 + <div class="flex flex-wrap items-center gap-2"> 184 + {#if rsvp.event.mode} 185 + <Badge size="sm" variant={getModeColor(rsvp.event.mode)} 186 + >{getModeLabel(rsvp.event.mode)}</Badge 187 + > 188 + {/if} 189 + 190 + <Badge size="sm" variant={getStatusColor(rsvp.status)} 191 + >{getStatusLabel(rsvp.status)}</Badge 192 + > 193 + </div> 194 + </div> 195 + </a> 196 + {/each} 197 + </div> 198 + {/if} 199 + </div> 200 + </div>
+37
src/routes/[[actor=actor]]/rsvp/api/refresh/+server.ts
··· 1 + import { createCache } from '$lib/cache'; 2 + import { error, json } from '@sveltejs/kit'; 3 + import { getActor } from '$lib/actor'; 4 + import { fetchUserRsvps, resolveProfile } from '$lib/events/fetch-attendees'; 5 + 6 + export async function GET({ params, platform, request }) { 7 + const cache = createCache(platform); 8 + if (!cache) return json('no cache'); 9 + 10 + const did = await getActor({ request, paramActor: params.actor, platform, blockBoth: false }); 11 + 12 + if (!did) { 13 + throw error(404, 'Not found'); 14 + } 15 + 16 + // Delete stale caches 17 + await Promise.all([ 18 + cache.delete('rsvps', did), 19 + cache.delete('ical', `${did}:rsvp-calendar`) 20 + ]).catch(() => {}); 21 + 22 + // Re-fetch and cache 23 + const [rsvps, userProfile] = await Promise.all([ 24 + fetchUserRsvps(did, cache), 25 + resolveProfile(did, cache) 26 + ]); 27 + 28 + const result = { 29 + rsvps, 30 + did, 31 + userProfile: userProfile ?? null 32 + }; 33 + 34 + await cache.putJSON('rsvps', did, result).catch(() => {}); 35 + 36 + return json(result); 37 + }
+106
src/routes/[[actor=actor]]/rsvp/calendar/+server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import { getCDNImageBlobUrl } from '$lib/atproto/methods.js'; 3 + import { createCache } from '$lib/cache'; 4 + import { getActor } from '$lib/actor'; 5 + import { generateICalFeed, type ICalAttendee, type ICalEvent } from '$lib/ical'; 6 + import { 7 + fetchEventRsvps, 8 + fetchUserRsvps, 9 + getProfileUrl, 10 + resolveProfile 11 + } from '$lib/events/fetch-attendees'; 12 + 13 + export async function GET({ params, platform, request }) { 14 + const cache = createCache(platform); 15 + 16 + const did = await getActor({ request, paramActor: params.actor, platform }); 17 + 18 + if (!did) { 19 + throw error(404, 'Not found'); 20 + } 21 + 22 + try { 23 + // Check cache first 24 + const cacheKey = `${did}:rsvp-calendar`; 25 + if (cache) { 26 + const cached = await cache.get('ical', cacheKey); 27 + if (cached) { 28 + return new Response(cached, { 29 + headers: { 30 + 'Content-Type': 'text/calendar; charset=utf-8', 31 + 'Cache-Control': 'public, max-age=3600' 32 + } 33 + }); 34 + } 35 + } 36 + 37 + const [rsvps, userProfile] = await Promise.all([ 38 + fetchUserRsvps(did, cache), 39 + resolveProfile(did, cache) 40 + ]); 41 + 42 + // Enrich each RSVP with attendees and image URLs for the iCal feed 43 + const events: ICalEvent[] = ( 44 + await Promise.all( 45 + rsvps.map(async (rsvp) => { 46 + try { 47 + const actor = rsvp.hostProfile?.handle || rsvp.hostDid; 48 + const thumbnail = rsvp.event.media?.find((m) => m.role === 'thumbnail'); 49 + const imageUrl = thumbnail?.content 50 + ? getCDNImageBlobUrl({ 51 + did: rsvp.hostDid, 52 + blob: thumbnail.content, 53 + type: 'jpeg' 54 + }) 55 + : undefined; 56 + 57 + const rsvpMap = await fetchEventRsvps(rsvp.eventUri).catch( 58 + () => new Map<string, 'going' | 'interested'>() 59 + ); 60 + const attendees: ICalAttendee[] = []; 61 + await Promise.all( 62 + Array.from(rsvpMap.entries()).map(async ([attendeeDid, status]) => { 63 + const profile = await resolveProfile(attendeeDid, cache).catch(() => null); 64 + attendees.push({ 65 + name: profile?.handle || attendeeDid, 66 + status, 67 + url: getProfileUrl(attendeeDid, profile) 68 + }); 69 + }) 70 + ); 71 + 72 + return { 73 + eventData: rsvp.event, 74 + uid: rsvp.eventUri, 75 + url: `https://blento.app/${actor}/events/${rsvp.rkey}`, 76 + organizer: actor, 77 + imageUrl, 78 + attendees 79 + } satisfies ICalEvent; 80 + } catch { 81 + return null; 82 + } 83 + }) 84 + ) 85 + ).filter((e) => e !== null); 86 + 87 + const actor = userProfile?.handle || did; 88 + const calendarName = `${userProfile?.displayName || actor}'s RSVP Events`; 89 + const ical = generateICalFeed(events, calendarName); 90 + 91 + // Store in cache 92 + if (cache) { 93 + await cache.put('ical', cacheKey, ical).catch(() => {}); 94 + } 95 + 96 + return new Response(ical, { 97 + headers: { 98 + 'Content-Type': 'text/calendar; charset=utf-8', 99 + 'Cache-Control': 'public, max-age=3600' 100 + } 101 + }); 102 + } catch (e) { 103 + if (e && typeof e === 'object' && 'status' in e) throw e; 104 + throw error(500, 'Failed to generate calendar'); 105 + } 106 + }