diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a2b5d10d..26d6c67f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,36 +1,36 @@ { - "permissions": { - "allow": [ - "Bash(pnpm check:*)", - "mcp__ide__getDiagnostics", - "mcp__plugin_svelte_svelte__svelte-autofixer", - "mcp__plugin_svelte_svelte__list-sections", - "Bash(pkill:*)", - "Bash(timeout 8 pnpm dev:*)", - "Bash(git checkout:*)", - "Bash(npx svelte-kit:*)", - "Bash(ls:*)", - "Bash(pnpm format:*)", - "Bash(pnpm add:*)", - "WebSearch", - "WebFetch(domain:github.com)", - "WebFetch(domain:flipclockjs.com)", - "WebFetch(domain:codepen.io)", - "WebFetch(domain:flo-bit.dev)", - "Bash(pnpm install)", - "Bash(pnpm install:*)", - "Bash(pnpm config:*)", - "Bash(lsof:*)", - "Bash(pnpm dev)", - "Bash(pnpm exec svelte-kit:*)", - "Bash(pnpm build:*)", - "Bash(pnpm remove:*)", - "Bash(grep:*)", - "Bash(find:*)", - "Bash(npx prettier:*)", - "Bash(node -e:*)", - "mcp__plugin_svelte_svelte__get-documentation", - "WebFetch(domain:bits-ui.com)" - ] - } + "permissions": { + "allow": [ + "Bash(pnpm check:*)", + "mcp__ide__getDiagnostics", + "mcp__plugin_svelte_svelte__svelte-autofixer", + "mcp__plugin_svelte_svelte__list-sections", + "Bash(pkill:*)", + "Bash(timeout 8 pnpm dev:*)", + "Bash(git checkout:*)", + "Bash(npx svelte-kit:*)", + "Bash(ls:*)", + "Bash(pnpm format:*)", + "Bash(pnpm add:*)", + "WebSearch", + "WebFetch(domain:github.com)", + "WebFetch(domain:flipclockjs.com)", + "WebFetch(domain:codepen.io)", + "WebFetch(domain:flo-bit.dev)", + "Bash(pnpm install)", + "Bash(pnpm install:*)", + "Bash(pnpm config:*)", + "Bash(lsof:*)", + "Bash(pnpm dev)", + "Bash(pnpm exec svelte-kit:*)", + "Bash(pnpm build:*)", + "Bash(pnpm remove:*)", + "Bash(grep:*)", + "Bash(find:*)", + "Bash(npx prettier:*)", + "Bash(node -e:*)", + "mcp__plugin_svelte_svelte__get-documentation", + "WebFetch(domain:bits-ui.com)" + ] + } } diff --git a/src/lib/cache.ts b/src/lib/cache.ts index d0e09817..02b0f21f 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -13,6 +13,8 @@ const NAMESPACE_TTL = { npmx: 60 * 60 * 12, // 12 hours profile: 60 * 60 * 24, // 24 hours ical: 60 * 60 * 2, // 2 hours + events: 60 * 60, // 1 hour + rsvps: 60 * 60, // 1 hour meta: 0 // no auto-expiry } as const; diff --git a/src/lib/cards/index.ts b/src/lib/cards/index.ts index e0c53f27..7c981fd7 100644 --- a/src/lib/cards/index.ts +++ b/src/lib/cards/index.ts @@ -27,6 +27,8 @@ import { PhotoGalleryCardDefinition } from './media/PhotoGalleryCard'; import { StandardSiteDocumentListCardDefinition } from './content/StandardSiteDocumentListCard'; import { StatusphereCardDefinition } from './media/StatusphereCard'; import { EventCardDefinition } from './social/EventCard'; +import { UpcomingEventsCardDefinition } from './social/UpcomingEventsCard'; +import { UpcomingRsvpsCardDefinition } from './social/UpcomingRsvpsCard'; import { VCardCardDefinition } from './social/VCardCard'; import { DrawCardDefinition } from './visual/DrawCard'; import { TimerCardDefinition } from './utilities/TimerCard'; @@ -83,6 +85,8 @@ export const AllCardDefinitions = [ StandardSiteDocumentListCardDefinition, StatusphereCardDefinition, EventCardDefinition, + UpcomingEventsCardDefinition, + UpcomingRsvpsCardDefinition, VCardCardDefinition, DrawCardDefinition, TimerCardDefinition, diff --git a/src/lib/cards/social/UpcomingEventsCard/UpcomingEventsCard.svelte b/src/lib/cards/social/UpcomingEventsCard/UpcomingEventsCard.svelte new file mode 100644 index 00000000..39a9f82a --- /dev/null +++ b/src/lib/cards/social/UpcomingEventsCard/UpcomingEventsCard.svelte @@ -0,0 +1,260 @@ + + +
+ +
+
+
+ + + +
+ Events +
+ {#if isOwner} +
+ + + + + + +
+ {/if} +
+ + +
+ {#if events.length > 0} + + {:else if isLoaded} +
+ No upcoming events +
+ {:else} +
+ Loading events... +
+ {/if} +
+
diff --git a/src/lib/cards/social/UpcomingEventsCard/index.ts b/src/lib/cards/social/UpcomingEventsCard/index.ts new file mode 100644 index 00000000..1879e298 --- /dev/null +++ b/src/lib/cards/social/UpcomingEventsCard/index.ts @@ -0,0 +1,52 @@ +import { listRecords } from '$lib/atproto'; +import type { CardDefinition } from '../../types'; +import UpcomingEventsCard from './UpcomingEventsCard.svelte'; +import type { Did } from '@atcute/lexicons'; +import type { EventData } from '../EventCard'; + +const EVENT_COLLECTION = 'community.lexicon.calendar.event'; + +export const UpcomingEventsCardDefinition = { + type: 'upcomingEvents', + contentComponent: UpcomingEventsCard, + createNew: (card) => { + card.w = 4; + card.h = 4; + card.mobileW = 8; + card.mobileH = 6; + }, + minW: 2, + minH: 3, + + loadData: async (_items, { did }) => { + const records = await listRecords({ + did: did as Did, + collection: EVENT_COLLECTION, + limit: 100 + }); + + const now = new Date(); + const events: Array = []; + + for (const record of records) { + const event = record.value as EventData; + const endsAt = event.endsAt ? new Date(event.endsAt) : null; + const startsAt = new Date(event.startsAt); + + if ((endsAt && endsAt >= now) || (!endsAt && startsAt >= now)) { + const uri = record.uri as string; + const rkey = uri.split('/').pop() || ''; + events.push({ ...event, rkey }); + } + } + + events.sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime()); + + return { events }; + }, + + name: 'Upcoming Events', + keywords: ['events', 'hosting', 'calendar', 'upcoming'], + groups: ['Social'], + icon: `` +} as CardDefinition & { type: 'upcomingEvents' }; diff --git a/src/lib/cards/social/UpcomingRsvpsCard/UpcomingRsvpsCard.svelte b/src/lib/cards/social/UpcomingRsvpsCard/UpcomingRsvpsCard.svelte new file mode 100644 index 00000000..e6e493ed --- /dev/null +++ b/src/lib/cards/social/UpcomingRsvpsCard/UpcomingRsvpsCard.svelte @@ -0,0 +1,162 @@ + + +
+
+
+ + + +
+ RSVPs +
+ + +
diff --git a/src/lib/cards/social/UpcomingRsvpsCard/index.ts b/src/lib/cards/social/UpcomingRsvpsCard/index.ts new file mode 100644 index 00000000..a663dfa1 --- /dev/null +++ b/src/lib/cards/social/UpcomingRsvpsCard/index.ts @@ -0,0 +1,41 @@ +import { fetchUserRsvps } from '$lib/events/fetch-attendees'; +import type { CardDefinition } from '../../types'; +import UpcomingRsvpsCard from './UpcomingRsvpsCard.svelte'; +import type { ResolvedRsvp } from '$lib/events/fetch-attendees'; + +export type { ResolvedRsvp }; + +export const UpcomingRsvpsCardDefinition = { + type: 'upcomingRsvps', + contentComponent: UpcomingRsvpsCard, + createNew: (card) => { + card.w = 4; + card.h = 4; + card.mobileW = 8; + card.mobileH = 6; + }, + minW: 2, + minH: 3, + + loadData: async (_items, { did, cache }) => { + const rsvps = await fetchUserRsvps(did, cache); + + const now = new Date(); + const upcoming = rsvps.filter((r) => { + const endsAt = r.event.endsAt ? new Date(r.event.endsAt) : null; + const startsAt = new Date(r.event.startsAt); + return (endsAt && endsAt >= now) || (!endsAt && startsAt >= now); + }); + + upcoming.sort( + (a, b) => new Date(a.event.startsAt).getTime() - new Date(b.event.startsAt).getTime() + ); + + return { rsvps: upcoming }; + }, + + name: 'Upcoming RSVPs', + keywords: ['rsvp', 'attending', 'going', 'interested', 'events'], + groups: ['Social'], + icon: `` +} as CardDefinition & { type: 'upcomingRsvps' }; diff --git a/src/lib/events/fetch-attendees.ts b/src/lib/events/fetch-attendees.ts index 495ba3be..7be55de3 100644 --- a/src/lib/events/fetch-attendees.ts +++ b/src/lib/events/fetch-attendees.ts @@ -1,9 +1,19 @@ -import { getBlentoOrBskyProfile, parseUri } from '$lib/atproto/methods'; +import { getBlentoOrBskyProfile, getRecord, listRecords, parseUri } from '$lib/atproto/methods'; import type { CacheService, CachedProfile } from '$lib/cache'; +import type { EventData } from '$lib/cards/social/EventCard'; import type { Did } from '@atcute/lexicons'; export type RsvpStatus = 'going' | 'interested'; +export interface ResolvedRsvp { + event: EventData; + rkey: string; + hostDid: string; + hostProfile: CachedProfile | null; + status: 'going' | 'interested'; + eventUri: string; +} + /** * Fetch raw RSVP data for an event from Microcosm Constellation backlinks. * Returns a map of DID -> status (going/interested). @@ -106,3 +116,65 @@ export function getProfileUrl(did: string, profile?: CachedProfile | null): stri const handle = profile?.handle; return handle ? `https://bsky.app/profile/${handle}` : `https://bsky.app/profile/${did}`; } + +interface RsvpRecord { + $type: string; + status: string; + subject: { uri: string; cid?: string }; + createdAt: string; +} + +/** + * Fetch a user's RSVPs (going/interested) and resolve each referenced event + host profile. + */ +export async function fetchUserRsvps( + did: string, + cache?: CacheService | null +): Promise { + const rsvpRecords = await listRecords({ + did: did as Did, + collection: 'community.lexicon.calendar.rsvp', + limit: 100 + }); + + const activeRsvps = rsvpRecords.filter((r) => { + const rsvp = r.value as unknown as RsvpRecord; + return rsvp.status?.endsWith('#going') || rsvp.status?.endsWith('#interested'); + }); + + const results = await Promise.all( + activeRsvps.map(async (r) => { + const rsvp = r.value as unknown as RsvpRecord; + const parsed = parseUri(rsvp.subject.uri); + if (!parsed?.rkey || !parsed?.repo) return null; + + try { + const [record, hostProfile] = await Promise.all([ + getRecord({ + did: parsed.repo as Did, + collection: 'community.lexicon.calendar.event', + rkey: parsed.rkey + }), + resolveProfile(parsed.repo, cache).catch(() => null) + ]); + + if (!record?.value) return null; + + return { + event: record.value as EventData, + rkey: parsed.rkey, + hostDid: parsed.repo, + hostProfile, + status: (rsvp.status?.endsWith('#going') ? 'going' : 'interested') as + | 'going' + | 'interested', + eventUri: rsvp.subject.uri + }; + } catch { + return null; + } + }) + ); + + return results.filter((r) => r !== null); +} diff --git a/src/lib/website/Account.svelte b/src/lib/website/Account.svelte index ce04db09..93b9255d 100644 --- a/src/lib/website/Account.svelte +++ b/src/lib/website/Account.svelte @@ -20,7 +20,7 @@ {#snippet child({ props })} {/snippet} diff --git a/src/routes/[[actor=actor]]/events/+page.server.ts b/src/routes/[[actor=actor]]/events/+page.server.ts index 976626eb..9475139c 100644 --- a/src/routes/[[actor=actor]]/events/+page.server.ts +++ b/src/routes/[[actor=actor]]/events/+page.server.ts @@ -15,6 +15,16 @@ export async function load({ params, platform, request }) { } try { + // Try cache first + if (cache) { + const cached = await cache.getJSON<{ + events: (EventData & { rkey: string })[]; + did: string; + hostProfile: CachedProfile | null; + }>('events', did); + if (cached) return cached; + } + const [records, hostProfile] = await Promise.all([ listRecords({ did: did as Did, @@ -42,11 +52,18 @@ export async function load({ params, platform, request }) { rkey: r.uri.split('/').pop() as string })); - return { + const result = { events, did, hostProfile: hostProfile ?? null }; + + // Cache the result + if (cache) { + await cache.putJSON('events', did, result).catch(() => {}); + } + + return result; } catch (e) { if (e && typeof e === 'object' && 'status' in e) throw e; throw error(404, 'Events not found'); diff --git a/src/routes/[[actor=actor]]/events/+page.svelte b/src/routes/[[actor=actor]]/events/+page.svelte index d69df228..80f5f3fa 100644 --- a/src/routes/[[actor=actor]]/events/+page.svelte +++ b/src/routes/[[actor=actor]]/events/+page.svelte @@ -2,7 +2,8 @@ import type { EventData } from '$lib/cards/social/EventCard'; import { getCDNImageBlobUrl } from '$lib/atproto'; import { user } from '$lib/atproto/auth.svelte'; - import { Avatar as FoxAvatar, Badge, Button } from '@foxui/core'; + import { Avatar as FoxAvatar, Badge, Button, toast } from '@foxui/core'; + import { page } from '$app/state'; import Avatar from 'svelte-boring-avatars'; import * as TID from '@atcute/tid'; import { goto } from '$app/navigation'; @@ -78,6 +79,16 @@ } let isOwner = $derived(user.isLoggedIn && user.did === did); + + let showPast: boolean = $state(false); + let now = $derived(new Date()); + let filteredEvents = $derived( + events.filter((e) => { + const endOrStart = e.endsAt || e.startsAt; + const eventDate = new Date(endOrStart); + return showPast ? eventDate < now : eventDate >= now; + }) + ); @@ -96,7 +107,7 @@

- Upcoming events + {showPast ? 'Past' : 'Upcoming'} events

Hosted by @@ -111,26 +122,54 @@
- {#if isOwner} +
+ {#if isOwner} + + {/if} { + const calendarUrl = `${page.url.origin}${page.url.pathname.replace(/\/$/, '')}/calendar`; + await navigator.clipboard.writeText(calendarUrl); + toast.success('Subscription link copied to clipboard'); + }}>Subscribe - {/if} +
+
+ + +
+ +
- {#if events.length === 0} -

No events found.

+ {#if filteredEvents.length === 0} +

+ No {showPast ? 'past' : 'upcoming'} events. +

{:else}
- {#each events as event (event.rkey)} + {#each filteredEvents as event (event.rkey)} {@const thumbnail = getThumbnail(event)} {@const location = getLocationString(event.locations)} {@const rkey = event.rkey} diff --git a/src/routes/[[actor=actor]]/events/[rkey]/+page.svelte b/src/routes/[[actor=actor]]/events/[rkey]/+page.svelte index ddc9d0cc..1d9ae3f1 100644 --- a/src/routes/[[actor=actor]]/events/[rkey]/+page.svelte +++ b/src/routes/[[actor=actor]]/events/[rkey]/+page.svelte @@ -217,7 +217,7 @@
{#if !isBannerOnly} @@ -245,7 +245,7 @@ {/if} -
+

{eventData.name} @@ -337,76 +337,77 @@ > About

-

+

{@html descriptionHtml}

{/if}
- - - - {#if eventData.uris && eventData.uris.length > 0} - -
+ +
+ +

- Links + Hosted By

-
- {#each eventData.uris as link (link.name + link.uri)} - - + + + {hostProfile?.displayName || hostProfile?.handle || did} + + +
+ + {#if eventData.uris && eventData.uris.length > 0} + +
+

+ Links +

+
+ {#each eventData.uris as link (link.name + link.uri)} + - - - {link.name || link.uri.replace(/^https?:\/\//, '')} - - {/each} + + + + {link.name || link.uri.replace(/^https?:\/\//, '')} + + {/each} +
-
- {/if} + {/if} - -
+ -
- -
+
diff --git a/src/routes/[[actor=actor]]/events/[rkey]/EventAttendees.svelte b/src/routes/[[actor=actor]]/events/[rkey]/EventAttendees.svelte index bfebcfc4..dfd090d5 100644 --- a/src/routes/[[actor=actor]]/events/[rkey]/EventAttendees.svelte +++ b/src/routes/[[actor=actor]]/events/[rkey]/EventAttendees.svelte @@ -166,7 +166,12 @@
{/if} - e.preventDefault()} class="p-0"> + e.preventDefault()} + class="p-0" +>

{modalTitle} diff --git a/src/routes/[[actor=actor]]/events/[rkey]/EventRsvp.svelte b/src/routes/[[actor=actor]]/events/[rkey]/EventRsvp.svelte index 37be43af..06c19ee3 100644 --- a/src/routes/[[actor=actor]]/events/[rkey]/EventRsvp.svelte +++ b/src/routes/[[actor=actor]]/events/[rkey]/EventRsvp.svelte @@ -94,6 +94,7 @@ rsvpStatus = status; rsvpRkey = key; onrsvp?.(status); + refreshRsvpCache(); } } catch (e) { console.error('Failed to submit RSVP:', e); @@ -113,12 +114,23 @@ rsvpStatus = null; rsvpRkey = null; oncancel?.(); + refreshRsvpCache(); } catch (e) { console.error('Failed to cancel RSVP:', e); } finally { rsvpSubmitting = false; } } + + function refreshRsvpCache() { + const handle = + user.profile?.handle && user.profile.handle !== 'handle.invalid' + ? user.profile.handle + : user.did; + if (handle) { + fetch(`/${handle}/rsvp/api/refresh`).catch(() => {}); + } + }

= e) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary local, not reactive state const adjusted = new Date(s); adjusted.setHours(adjusted.getHours() + 1); endsAt = isoToDatetimeLocal(adjusted.toISOString()); @@ -564,6 +565,7 @@ user.profile?.handle && user.profile.handle !== 'handle.invalid' ? user.profile.handle : user.did; + fetch(`/${handle}/events/api/refresh`).catch(() => {}); goto(`/${handle}/events/${rkey}`); } else { error = `Failed to ${isNew ? 'create' : 'save'} event. Please try again.`; @@ -592,6 +594,7 @@ user.profile?.handle && user.profile.handle !== 'handle.invalid' ? user.profile.handle : user.did; + fetch(`/${handle}/events/api/refresh`).catch(() => {}); goto(`/${handle}/events`); } catch (e) { console.error('Failed to delete event:', e); @@ -1165,22 +1168,12 @@ > Cancel -
{:else} - + {/if}
{/if} diff --git a/src/routes/[[actor=actor]]/events/api/refresh/+server.ts b/src/routes/[[actor=actor]]/events/api/refresh/+server.ts new file mode 100644 index 00000000..d571444c --- /dev/null +++ b/src/routes/[[actor=actor]]/events/api/refresh/+server.ts @@ -0,0 +1,47 @@ +import { createCache } from '$lib/cache'; +import { error, json } from '@sveltejs/kit'; +import { getActor } from '$lib/actor'; +import { listRecords } from '$lib/atproto/methods.js'; +import type { EventData } from '$lib/cards/social/EventCard'; +import type { Did } from '@atcute/lexicons'; + +export async function GET({ params, platform, request }) { + const cache = createCache(platform); + if (!cache) return json('no cache'); + + const did = await getActor({ request, paramActor: params.actor, platform, blockBoth: false }); + + if (!did) { + throw error(404, 'Not found'); + } + + // Delete stale caches + await Promise.all([cache.delete('events', did), cache.delete('ical', `${did}:calendar`)]).catch( + () => {} + ); + + // Re-fetch and cache + const [records, hostProfile] = await Promise.all([ + listRecords({ + did: did as Did, + collection: 'community.lexicon.calendar.event', + limit: 100 + }), + cache.getProfile(did as Did).catch(() => null) + ]); + + const events = records.map((r) => ({ + ...(r.value as EventData), + rkey: r.uri.split('/').pop() as string + })); + + const result = { + events, + did, + hostProfile: hostProfile ?? null + }; + + await cache.putJSON('events', did, result).catch(() => {}); + + return json(result); +} diff --git a/src/routes/[[actor=actor]]/events/calendar/+server.ts b/src/routes/[[actor=actor]]/events/calendar/+server.ts index 0d59814b..75d732a8 100644 --- a/src/routes/[[actor=actor]]/events/calendar/+server.ts +++ b/src/routes/[[actor=actor]]/events/calendar/+server.ts @@ -4,7 +4,7 @@ import { getCDNImageBlobUrl, listRecords } from '$lib/atproto/methods.js'; import { createCache } from '$lib/cache'; import type { Did } from '@atcute/lexicons'; import { getActor } from '$lib/actor'; -import { generateICalFeed, type ICalEvent } from '$lib/ical'; +import { generateICalFeed, type ICalAttendee, type ICalEvent } from '$lib/ical'; import { fetchEventRsvps, getProfileUrl, resolveProfile } from '$lib/events/fetch-attendees'; export async function GET({ params, platform, request }) { diff --git a/src/routes/[[actor=actor]]/events/rsvp-calendar/+server.ts b/src/routes/[[actor=actor]]/events/rsvp-calendar/+server.ts deleted file mode 100644 index 000a85e0..00000000 --- a/src/routes/[[actor=actor]]/events/rsvp-calendar/+server.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { error } from '@sveltejs/kit'; -import type { EventData } from '$lib/cards/social/EventCard'; -import { getCDNImageBlobUrl, getRecord, listRecords, parseUri } from '$lib/atproto/methods.js'; -import { createCache } from '$lib/cache'; -import type { Did } from '@atcute/lexicons'; -import { getActor } from '$lib/actor'; -import { generateICalFeed, type ICalAttendee, type ICalEvent } from '$lib/ical'; -import { fetchEventRsvps, getProfileUrl, resolveProfile } from '$lib/events/fetch-attendees'; - -interface RsvpRecord { - $type: string; - status: string; - subject: { uri: string; cid?: string }; - createdAt: string; -} - -export async function GET({ params, platform, request }) { - const cache = createCache(platform); - - const did = await getActor({ request, paramActor: params.actor, platform }); - - if (!did) { - throw error(404, 'Not found'); - } - - try { - // Check cache first - const cacheKey = `${did}:rsvp-calendar`; - if (cache) { - const cached = await cache.get('ical', cacheKey); - if (cached) { - return new Response(cached, { - headers: { - 'Content-Type': 'text/calendar; charset=utf-8', - 'Cache-Control': 'public, max-age=3600' - } - }); - } - } - - const [rsvpRecords, hostProfile] = await Promise.all([ - listRecords({ - did: did as Did, - collection: 'community.lexicon.calendar.rsvp', - limit: 100 - }), - resolveProfile(did, cache) - ]); - - // Filter to only going and interested RSVPs - const activeRsvps = rsvpRecords.filter((r) => { - const rsvp = r.value as unknown as RsvpRecord; - return rsvp.status?.endsWith('#going') || rsvp.status?.endsWith('#interested'); - }); - - // Fetch each referenced event in parallel - const eventResults = await Promise.all( - activeRsvps.map(async (r) => { - const rsvp = r.value as unknown as RsvpRecord; - const parsed = parseUri(rsvp.subject.uri); - if (!parsed?.rkey || !parsed?.repo) return null; - - try { - const [record, organizerProfile] = await Promise.all([ - getRecord({ - did: parsed.repo as Did, - collection: 'community.lexicon.calendar.event', - rkey: parsed.rkey - }), - resolveProfile(parsed.repo, cache).catch(() => null) - ]); - if (!record?.value) return null; - const eventData = record.value as EventData; - const actor = organizerProfile?.handle || parsed.repo; - const thumbnail = eventData.media?.find((m) => m.role === 'thumbnail'); - const imageUrl = thumbnail?.content - ? getCDNImageBlobUrl({ - did: parsed.repo, - blob: thumbnail.content, - type: 'jpeg' - }) - : undefined; - - // Fetch RSVPs and resolve handles - const rsvpMap = await fetchEventRsvps(rsvp.subject.uri).catch( - () => new Map() - ); - const attendees: ICalAttendee[] = []; - await Promise.all( - Array.from(rsvpMap.entries()).map(async ([attendeeDid, status]) => { - const profile = await resolveProfile(attendeeDid, cache).catch(() => null); - attendees.push({ - name: profile?.handle || attendeeDid, - status, - url: getProfileUrl(attendeeDid, profile) - }); - }) - ); - - return { - eventData, - uid: rsvp.subject.uri, - url: `https://blento.app/${actor}/events/${parsed.rkey}`, - organizer: actor, - imageUrl, - attendees - } satisfies ICalEvent; - } catch { - return null; - } - }) - ); - - const events: ICalEvent[] = eventResults.filter((e) => e !== null); - - const actor = hostProfile?.handle || did; - const calendarName = `${hostProfile?.displayName || actor}'s RSVP Events`; - const ical = generateICalFeed(events, calendarName); - - // Store in cache - if (cache) { - await cache.put('ical', cacheKey, ical).catch(() => {}); - } - - return new Response(ical, { - headers: { - 'Content-Type': 'text/calendar; charset=utf-8', - 'Cache-Control': 'public, max-age=3600' - } - }); - } catch (e) { - if (e && typeof e === 'object' && 'status' in e) throw e; - throw error(500, 'Failed to generate calendar'); - } -} diff --git a/src/routes/[[actor=actor]]/rsvp/+layout.server.ts b/src/routes/[[actor=actor]]/rsvp/+layout.server.ts new file mode 100644 index 00000000..5a88b2b4 --- /dev/null +++ b/src/routes/[[actor=actor]]/rsvp/+layout.server.ts @@ -0,0 +1,28 @@ +import { getRecord } from '$lib/atproto/methods.js'; +import type { Did } from '@atcute/lexicons'; +import { getActor } from '$lib/actor.js'; + +export async function load({ params, platform, request }) { + const did = await getActor({ request, paramActor: params.actor, platform }); + + if (!did) return { accentColor: undefined, baseColor: undefined }; + + try { + const publication = await getRecord({ + did: did as Did, + collection: 'site.standard.publication', + rkey: 'blento.self' + }); + + const preferences = publication?.value?.preferences as + | { accentColor?: string; baseColor?: string } + | undefined; + + return { + accentColor: preferences?.accentColor, + baseColor: preferences?.baseColor + }; + } catch { + return { accentColor: undefined, baseColor: undefined }; + } +} diff --git a/src/routes/[[actor=actor]]/rsvp/+layout.svelte b/src/routes/[[actor=actor]]/rsvp/+layout.svelte new file mode 100644 index 00000000..ea1c6fce --- /dev/null +++ b/src/routes/[[actor=actor]]/rsvp/+layout.svelte @@ -0,0 +1,9 @@ + + + + +{@render children()} diff --git a/src/routes/[[actor=actor]]/rsvp/+page.server.ts b/src/routes/[[actor=actor]]/rsvp/+page.server.ts new file mode 100644 index 00000000..9dc01603 --- /dev/null +++ b/src/routes/[[actor=actor]]/rsvp/+page.server.ts @@ -0,0 +1,48 @@ +import { error } from '@sveltejs/kit'; +import { createCache } from '$lib/cache'; +import type { CachedProfile } from '$lib/cache'; +import { getActor } from '$lib/actor.js'; +import { fetchUserRsvps, resolveProfile, type ResolvedRsvp } from '$lib/events/fetch-attendees'; + +export async function load({ params, platform, request }) { + const cache = createCache(platform); + + const did = await getActor({ request, paramActor: params.actor, platform }); + + if (!did) { + throw error(404, 'RSVPs not found'); + } + + try { + // Try cache first + if (cache) { + const cached = await cache.getJSON<{ + rsvps: ResolvedRsvp[]; + did: string; + userProfile: CachedProfile | null; + }>('rsvps', did); + if (cached) return cached; + } + + const [rsvps, userProfile] = await Promise.all([ + fetchUserRsvps(did, cache), + resolveProfile(did, cache) + ]); + + const result = { + rsvps, + did, + userProfile: userProfile ?? null + }; + + // Cache the result + if (cache) { + await cache.putJSON('rsvps', did, result).catch(() => {}); + } + + return result; + } catch (e) { + if (e && typeof e === 'object' && 'status' in e) throw e; + throw error(404, 'RSVPs not found'); + } +} diff --git a/src/routes/[[actor=actor]]/rsvp/+page.svelte b/src/routes/[[actor=actor]]/rsvp/+page.svelte new file mode 100644 index 00000000..85f352c1 --- /dev/null +++ b/src/routes/[[actor=actor]]/rsvp/+page.svelte @@ -0,0 +1,200 @@ + + + + {userName} - RSVPs + + + + + + + + +
+
+ +
+
+

+ {showPast ? 'Past' : 'Upcoming'} RSVPs +

+
+ + {userName} +
+
+ +
+ + +
+ + +
+ + {#if filteredRsvps.length === 0} +

+ No {showPast ? 'past' : 'upcoming'} RSVPs. +

+ {:else} +
+ {#each filteredRsvps as rsvp (rsvp.eventUri)} + {@const thumbnail = getThumbnail(rsvp.event, rsvp.hostDid)} + {@const hostHandle = rsvp.hostProfile?.handle || rsvp.hostDid} + + +
+ {#if thumbnail} + {thumbnail.alt} + {:else} +
+ +
+ {/if} +
+ + +
+

+ {rsvp.event.name} +

+ +

+ {formatDate(rsvp.event.startsAt)} · {formatTime(rsvp.event.startsAt)} +

+ +
+ {#if rsvp.event.mode} + {getModeLabel(rsvp.event.mode)} + {/if} + + {getStatusLabel(rsvp.status)} +
+
+
+ {/each} +
+ {/if} +
+
diff --git a/src/routes/[[actor=actor]]/rsvp/api/refresh/+server.ts b/src/routes/[[actor=actor]]/rsvp/api/refresh/+server.ts new file mode 100644 index 00000000..b0d32b97 --- /dev/null +++ b/src/routes/[[actor=actor]]/rsvp/api/refresh/+server.ts @@ -0,0 +1,37 @@ +import { createCache } from '$lib/cache'; +import { error, json } from '@sveltejs/kit'; +import { getActor } from '$lib/actor'; +import { fetchUserRsvps, resolveProfile } from '$lib/events/fetch-attendees'; + +export async function GET({ params, platform, request }) { + const cache = createCache(platform); + if (!cache) return json('no cache'); + + const did = await getActor({ request, paramActor: params.actor, platform, blockBoth: false }); + + if (!did) { + throw error(404, 'Not found'); + } + + // Delete stale caches + await Promise.all([ + cache.delete('rsvps', did), + cache.delete('ical', `${did}:rsvp-calendar`) + ]).catch(() => {}); + + // Re-fetch and cache + const [rsvps, userProfile] = await Promise.all([ + fetchUserRsvps(did, cache), + resolveProfile(did, cache) + ]); + + const result = { + rsvps, + did, + userProfile: userProfile ?? null + }; + + await cache.putJSON('rsvps', did, result).catch(() => {}); + + return json(result); +} diff --git a/src/routes/[[actor=actor]]/rsvp/calendar/+server.ts b/src/routes/[[actor=actor]]/rsvp/calendar/+server.ts new file mode 100644 index 00000000..c0f038ee --- /dev/null +++ b/src/routes/[[actor=actor]]/rsvp/calendar/+server.ts @@ -0,0 +1,106 @@ +import { error } from '@sveltejs/kit'; +import { getCDNImageBlobUrl } from '$lib/atproto/methods.js'; +import { createCache } from '$lib/cache'; +import { getActor } from '$lib/actor'; +import { generateICalFeed, type ICalAttendee, type ICalEvent } from '$lib/ical'; +import { + fetchEventRsvps, + fetchUserRsvps, + getProfileUrl, + resolveProfile +} from '$lib/events/fetch-attendees'; + +export async function GET({ params, platform, request }) { + const cache = createCache(platform); + + const did = await getActor({ request, paramActor: params.actor, platform }); + + if (!did) { + throw error(404, 'Not found'); + } + + try { + // Check cache first + const cacheKey = `${did}:rsvp-calendar`; + if (cache) { + const cached = await cache.get('ical', cacheKey); + if (cached) { + return new Response(cached, { + headers: { + 'Content-Type': 'text/calendar; charset=utf-8', + 'Cache-Control': 'public, max-age=3600' + } + }); + } + } + + const [rsvps, userProfile] = await Promise.all([ + fetchUserRsvps(did, cache), + resolveProfile(did, cache) + ]); + + // Enrich each RSVP with attendees and image URLs for the iCal feed + const events: ICalEvent[] = ( + await Promise.all( + rsvps.map(async (rsvp) => { + try { + const actor = rsvp.hostProfile?.handle || rsvp.hostDid; + const thumbnail = rsvp.event.media?.find((m) => m.role === 'thumbnail'); + const imageUrl = thumbnail?.content + ? getCDNImageBlobUrl({ + did: rsvp.hostDid, + blob: thumbnail.content, + type: 'jpeg' + }) + : undefined; + + const rsvpMap = await fetchEventRsvps(rsvp.eventUri).catch( + () => new Map() + ); + const attendees: ICalAttendee[] = []; + await Promise.all( + Array.from(rsvpMap.entries()).map(async ([attendeeDid, status]) => { + const profile = await resolveProfile(attendeeDid, cache).catch(() => null); + attendees.push({ + name: profile?.handle || attendeeDid, + status, + url: getProfileUrl(attendeeDid, profile) + }); + }) + ); + + return { + eventData: rsvp.event, + uid: rsvp.eventUri, + url: `https://blento.app/${actor}/events/${rsvp.rkey}`, + organizer: actor, + imageUrl, + attendees + } satisfies ICalEvent; + } catch { + return null; + } + }) + ) + ).filter((e) => e !== null); + + const actor = userProfile?.handle || did; + const calendarName = `${userProfile?.displayName || actor}'s RSVP Events`; + const ical = generateICalFeed(events, calendarName); + + // Store in cache + if (cache) { + await cache.put('ical', cacheKey, ical).catch(() => {}); + } + + return new Response(ical, { + headers: { + 'Content-Type': 'text/calendar; charset=utf-8', + 'Cache-Control': 'public, max-age=3600' + } + }); + } catch (e) { + if (e && typeof e === 'object' && 'status' in e) throw e; + throw error(500, 'Failed to generate calendar'); + } +}