atmo.rsvp
1
fork

Configure Feed

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

add calendar

Florian 078a3d94 e8c00693

+192 -4
+1 -1
src/routes/calendar/+page.server.ts
··· 54 54 }) 55 55 .sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime()); 56 56 57 - return { events, loggedIn: true }; 57 + return { events, loggedIn: true, did: locals.did }; 58 58 };
+107 -3
src/routes/calendar/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import EventCard from '$lib/components/EventCard.svelte'; 3 - import { Button } from '@foxui/core'; 3 + import { Button, Modal } from '@foxui/core'; 4 4 import { atProtoLoginModalState } from '@foxui/social'; 5 + import { user } from '$lib/atproto/auth.svelte'; 5 6 6 7 let { data } = $props(); 8 + 9 + let calendarModalOpen = $state(false); 10 + let copied = $state(false); 11 + 12 + let calendarUrl = $derived( 13 + user.profile?.handle ? `https://atmo.rsvp/p/${user.profile.handle}/calendar.ics` : '' 14 + ); 15 + 16 + async function copyAndShowModal() { 17 + if (!calendarUrl) return; 18 + try { 19 + await navigator.clipboard.writeText(calendarUrl); 20 + copied = true; 21 + setTimeout(() => { 22 + copied = false; 23 + }, 3000); 24 + } catch { 25 + // clipboard may fail silently 26 + } 27 + calendarModalOpen = true; 28 + } 7 29 </script> 8 30 9 31 <svelte:head> ··· 11 33 </svelte:head> 12 34 13 35 <div class="mx-auto max-w-3xl px-6 py-8 sm:py-12"> 14 - <h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold">Calendar</h1> 15 - <h1 class="text-base-700 dark:text-base-300 mb-8 text-sm"> 36 + <div class="mb-2 flex items-center justify-between"> 37 + <h1 class="text-base-900 dark:text-base-50 text-2xl font-bold">Calendar</h1> 38 + {#if data.loggedIn && data.events.length > 0} 39 + <Button variant="secondary" onclick={copyAndShowModal}> 40 + <svg 41 + xmlns="http://www.w3.org/2000/svg" 42 + viewBox="0 0 20 20" 43 + fill="currentColor" 44 + class="h-4 w-4 sm:mr-1.5" 45 + > 46 + <path 47 + fill-rule="evenodd" 48 + d="M5.75 2a.75.75 0 0 1 .75.75V4h7V2.75a.75.75 0 0 1 1.5 0V4h.25A2.75 2.75 0 0 1 18 6.75v8.5A2.75 2.75 0 0 1 15.25 18H4.75A2.75 2.75 0 0 1 2 15.25v-8.5A2.75 2.75 0 0 1 4.75 4H5V2.75A.75.75 0 0 1 5.75 2Zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75Z" 49 + clip-rule="evenodd" 50 + /> 51 + </svg> 52 + <span class="hidden sm:inline">Add to your calendar</span> 53 + </Button> 54 + {/if} 55 + </div> 56 + <h1 class="text-base-700 dark:text-base-300 mb-8 mt-4 text-sm"> 16 57 Upcoming events you're hosting, attending or interested in 17 58 </h1> 18 59 ··· 41 82 <EventCard {event} /> 42 83 {/each} 43 84 </div> 85 + 86 + <Modal bind:open={calendarModalOpen}> 87 + <div class="min-w-0 space-y-4 overflow-hidden"> 88 + <h2 class="text-base-900 dark:text-base-50 text-lg font-semibold"> 89 + Add to your calendar 90 + </h2> 91 + <p class="text-base-600 dark:text-base-400 text-sm"> 92 + Subscribe to your events calendar using the URL below. Your calendar will stay 93 + in sync automatically as you RSVP to new events. 94 + </p> 95 + 96 + <button 97 + class="border-base-200 dark:border-base-700 bg-base-50 dark:bg-base-900 flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 transition-colors hover:bg-base-100 dark:hover:bg-base-800" 98 + onclick={async () => { 99 + try { 100 + await navigator.clipboard.writeText(calendarUrl); 101 + copied = true; 102 + setTimeout(() => { copied = false; }, 3000); 103 + } catch { 104 + // ignore 105 + } 106 + }} 107 + > 108 + <code class="text-base-700 dark:text-base-300 flex-1 truncate text-left text-xs"> 109 + {calendarUrl} 110 + </code> 111 + <span class="text-base-500 shrink-0 text-xs font-medium"> 112 + {copied ? 'Copied!' : 'Copy'} 113 + </span> 114 + </button> 115 + 116 + <div class="space-y-3 pt-2"> 117 + <div> 118 + <h3 class="text-base-800 dark:text-base-200 text-sm font-medium"> 119 + Apple Calendar 120 + </h3> 121 + <ol 122 + class="text-base-600 dark:text-base-400 mt-1 list-inside list-decimal space-y-0.5 text-sm" 123 + > 124 + <li>Open Apple Calendar</li> 125 + <li>Go to File &rarr; New Calendar Subscription</li> 126 + <li>Paste the URL above and click Subscribe</li> 127 + </ol> 128 + </div> 129 + 130 + <div> 131 + <h3 class="text-base-800 dark:text-base-200 text-sm font-medium"> 132 + Google Calendar 133 + </h3> 134 + <ol 135 + class="text-base-600 dark:text-base-400 mt-1 list-inside list-decimal space-y-0.5 text-sm" 136 + > 137 + <li>Open Google Calendar in your browser</li> 138 + <li> 139 + Click the + next to "Other calendars" in the sidebar 140 + </li> 141 + <li>Select "From URL"</li> 142 + <li>Paste the URL above and click "Add calendar"</li> 143 + </ol> 144 + </div> 145 + </div> 146 + </div> 147 + </Modal> 44 148 {/if} 45 149 </div>
+84
src/routes/p/[actor]/calendar.ics/+server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import { getActor } from '$lib/actor'; 3 + import { generateICalFeed, type ICalEvent } from '$lib/cal/ical'; 4 + import { isActorIdentifier, type ActorIdentifier } from '@atcute/lexicons/syntax'; 5 + import { 6 + contrail, 7 + flattenEventRecord, 8 + flattenEventRecords, 9 + listEventRecordsFromContrail 10 + } from '$lib/contrail'; 11 + 12 + export async function GET({ params }) { 13 + if (!isActorIdentifier(params.actor)) { 14 + throw error(404, 'Not found'); 15 + } 16 + 17 + const did = await getActor(params.actor); 18 + 19 + if (!did) { 20 + throw error(404, 'Not found'); 21 + } 22 + 23 + try { 24 + const now = new Date().toISOString(); 25 + 26 + const actorId = did as ActorIdentifier; 27 + const [rsvpResponse, hostingResponse] = await Promise.all([ 28 + contrail.get('community.lexicon.calendar.rsvp.listRecords', { 29 + params: { actor: actorId, hydrateEvent: true, limit: 100 } 30 + }), 31 + listEventRecordsFromContrail({ 32 + actor: actorId, 33 + startsAtMin: now, 34 + sort: 'startsAt', 35 + order: 'asc', 36 + limit: 100 37 + }) 38 + ]); 39 + 40 + const nowDate = new Date(); 41 + 42 + const rsvpEvents = (rsvpResponse.ok ? (rsvpResponse.data.records ?? []) : []) 43 + .filter((r) => { 44 + const status = r.record?.status; 45 + return status?.endsWith('#going') || status?.endsWith('#interested'); 46 + }) 47 + .flatMap((r) => { 48 + if (!r.event) return []; 49 + const flat = flattenEventRecord(r.event); 50 + return flat ? [flat] : []; 51 + }) 52 + .filter((e) => new Date(e.endsAt || e.startsAt) >= nowDate); 53 + 54 + const hostingEvents = hostingResponse ? flattenEventRecords(hostingResponse.records) : []; 55 + 56 + const seen = new Set<string>(); 57 + const allEvents = [...rsvpEvents, ...hostingEvents] 58 + .filter((e) => { 59 + if (seen.has(e.uri)) return false; 60 + seen.add(e.uri); 61 + return true; 62 + }) 63 + .sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime()); 64 + 65 + const events: ICalEvent[] = allEvents.map((r) => ({ 66 + eventData: r, 67 + uid: r.uri, 68 + url: `https://atmo.rsvp/p/${params.actor}/e/${r.rkey}` 69 + })); 70 + 71 + const calendarName = `${params.actor}'s Calendar`; 72 + const ical = generateICalFeed(events, calendarName); 73 + 74 + return new Response(ical, { 75 + headers: { 76 + 'Content-Type': 'text/calendar; charset=utf-8', 77 + 'Cache-Control': 'public, max-age=3600' 78 + } 79 + }); 80 + } catch (e) { 81 + if (e && typeof e === 'object' && 'status' in e) throw e; 82 + throw error(500, 'Failed to generate calendar'); 83 + } 84 + }