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 @@
+
+
+
+
+
+
+ {#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 @@
+
+
+
+
+
+
+ {#if rsvps.length > 0}
+
+ {:else if isLoaded}
+
+ No upcoming RSVPs
+
+ {:else}
+
+ Loading RSVPs...
+
+ {/if}
+
+
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
-
+
+ {#if eventData.uris && eventData.uris.length > 0}
+
+
-
- {/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}
- (showDeleteConfirm = true)}
- >
- Delete event
-
+ (showDeleteConfirm = true)}>Delete event
{/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}
+
+
+
{
+ const calendarUrl = `${page.url.origin}${page.url.pathname.replace(/\/$/, '')}/calendar`;
+ await navigator.clipboard.writeText(calendarUrl);
+ toast.success('Subscription link copied to clipboard');
+ }}>Subscribe
+
+
+
+
+ (showPast = false)}>Upcoming
+ (showPast = true)}>Past
+
+
+ {#if filteredRsvps.length === 0}
+
+ No {showPast ? 'past' : 'upcoming'} RSVPs.
+
+ {:else}
+
+ {/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');
+ }
+}