atmo.rsvp
3
fork

Configure Feed

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

Merge pull request #31 from flo-bit/feat/improve-main-page

Feat/improve main page

authored by

Florian and committed by
GitHub
2953d729 a37f840c

+515 -65
+45 -1
README.md
··· 1 - # atmo events 1 + # atmo.rsvp 2 + 3 + events for the open social web, built on atproto. 4 + 5 + https://atmo.rsvp 6 + 7 + uses `community.lexicon.calendar.event` and `community.lexicon.calendar.rsvp`. 8 + 9 + features: 10 + - event creation 11 + - rsvp to events 12 + - add your events to any ical compatible calendar 13 + (go to calendar/ when signed in and click "Add to your calendar") 14 + - post your events/rsvps to bluesky or anywhere else with nice open-graph images 15 + 16 + ## development 17 + 18 + clone repo 19 + 20 + ``` 21 + pnpm install 22 + ``` 23 + 24 + set remote to false in `wrangler.jsonc` L22: 25 + 26 + ``` 27 + "remote": false 28 + ``` 29 + 30 + optionally if you want all current events to be displayed run this: (will take a few minutes) 31 + 32 + ``` 33 + pnpm run sync 34 + ``` 35 + 36 + start dev server: 37 + 38 + ``` 39 + pnpm run dev 40 + ``` 41 + 42 + ## contributing 43 + 44 + open for contributions by all :) 45 +
+121
src/lib/components/RecentActivity.svelte
··· 1 + <script lang="ts"> 2 + import type { ActivityCluster } from '$lib/contrail'; 3 + import { eventUrl } from '$lib/contrail'; 4 + import type { AttendeeInfo } from '$lib/contrail'; 5 + 6 + let { activities }: { activities: ActivityCluster[] } = $props(); 7 + 8 + function visibleAttendees(cluster: ActivityCluster): { 9 + shown: AttendeeInfo[]; 10 + status: 'going' | 'interested'; 11 + } { 12 + const going = cluster.attendees.filter((a) => a.status === 'going'); 13 + if (going.length > 0) return { shown: going, status: 'going' }; 14 + return { shown: cluster.attendees, status: 'interested' }; 15 + } 16 + 17 + function namesSentence(attendees: AttendeeInfo[]): string { 18 + const names = attendees.map((a) => a.name); 19 + if (names.length === 0) return ''; 20 + if (names.length === 1) return names[0]; 21 + if (names.length === 2) return `${names[0]} and ${names[1]}`; 22 + const others = names.length - 2; 23 + return `${names[0]}, ${names[1]}, and ${others} other${others === 1 ? '' : 's'}`; 24 + } 25 + 26 + function verb(count: number, status: 'going' | 'interested'): string { 27 + const plural = count > 1; 28 + if (status === 'going') return plural ? 'are going' : 'is going'; 29 + return plural ? 'are interested' : 'is interested'; 30 + } 31 + 32 + function relativeTime(timeUs: number): string { 33 + const ageMs = Date.now() - timeUs / 1000; 34 + const sec = Math.max(0, Math.floor(ageMs / 1000)); 35 + if (sec < 60) return 'just now'; 36 + const mins = Math.floor(sec / 60); 37 + if (mins < 60) return `${mins}m ago`; 38 + const hours = Math.floor(mins / 60); 39 + if (hours < 24) return `${hours}h ago`; 40 + const days = Math.floor(hours / 24); 41 + if (days < 7) return `${days}d ago`; 42 + const weeks = Math.floor(days / 7); 43 + return `${weeks}w ago`; 44 + } 45 + 46 + function initial(name: string): string { 47 + const trimmed = name.trim(); 48 + if (!trimmed) return '?'; 49 + return trimmed.slice(0, 1).toUpperCase(); 50 + } 51 + </script> 52 + 53 + <ul class="divide-base-200 dark:divide-base-800 divide-y"> 54 + {#each activities as cluster (cluster.event.uri)} 55 + {@const { shown, status } = visibleAttendees(cluster)} 56 + {@const eventTitle = cluster.event.name || 'Untitled event'} 57 + <li> 58 + <a 59 + href={eventUrl(cluster.event)} 60 + class="hover:bg-base-100 dark:hover:bg-base-900/50 -mx-2 block rounded-lg px-2 py-3 transition-colors" 61 + > 62 + <div class="flex items-baseline justify-between gap-2"> 63 + <h3 64 + class="text-base-900 dark:text-base-50 min-w-0 truncate text-base font-semibold" 65 + > 66 + {eventTitle} 67 + </h3> 68 + <span class="text-base-500 shrink-0 text-xs"> 69 + {relativeTime(cluster.latestTimeUs)} 70 + </span> 71 + </div> 72 + <div class="mt-2 flex items-center gap-2"> 73 + <div class="flex shrink-0 -space-x-1.5"> 74 + {#if shown.length >= 4} 75 + {#each shown.slice(0, 2) as attendee (attendee.did)} 76 + {#if attendee.avatar} 77 + <img 78 + src={attendee.avatar} 79 + alt="" 80 + class="ring-base-50 dark:ring-base-900 h-6 w-6 rounded-full object-cover ring-2" 81 + /> 82 + {:else} 83 + <div 84 + class="bg-base-300 dark:bg-base-700 text-base-700 dark:text-base-200 ring-base-50 dark:ring-base-900 flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-medium ring-2" 85 + > 86 + {initial(attendee.name)} 87 + </div> 88 + {/if} 89 + {/each} 90 + <span 91 + class="bg-base-200 dark:bg-base-800 text-base-700 dark:text-base-200 ring-base-50 dark:ring-base-900 inline-flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-semibold ring-2" 92 + > 93 + +{shown.length - 2} 94 + </span> 95 + {:else} 96 + {#each shown as attendee (attendee.did)} 97 + {#if attendee.avatar} 98 + <img 99 + src={attendee.avatar} 100 + alt="" 101 + class="ring-base-50 dark:ring-base-900 h-6 w-6 rounded-full object-cover ring-2" 102 + /> 103 + {:else} 104 + <div 105 + class="bg-base-300 dark:bg-base-700 text-base-700 dark:text-base-200 ring-base-50 dark:ring-base-900 flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-medium ring-2" 106 + > 107 + {initial(attendee.name)} 108 + </div> 109 + {/if} 110 + {/each} 111 + {/if} 112 + </div> 113 + <p class="text-base-600 dark:text-base-400 min-w-0 truncate text-sm"> 114 + <span class="text-base-800 dark:text-base-200">{namesSentence(shown)}</span> 115 + {verb(shown.length, status)} 116 + </p> 117 + </div> 118 + </a> 119 + </li> 120 + {/each} 121 + </ul>
+7 -1
src/lib/contrail.ts
··· 66 66 interestedCount: number; 67 67 }; 68 68 69 + export type ActivityCluster = { 70 + event: FlatEventRecord; 71 + attendees: AttendeeInfo[]; 72 + latestTimeUs: number; 73 + }; 74 + 69 75 type ListEventsParams = { 70 76 actor?: ActorIdentifier; 71 77 search?: string; ··· 160 166 }; 161 167 } 162 168 163 - function buildAttendee( 169 + export function buildAttendee( 164 170 did: string, 165 171 status: 'going' | 'interested', 166 172 profiles?: EventProfiles | RsvpProfileEntry[]
+135 -8
src/routes/(app)/+page.server.ts
··· 1 1 import { 2 + buildAttendee, 3 + flattenEventRecord, 2 4 flattenEventRecords, 3 5 getServerClient, 4 - listDiscoverableEventsFromContrail 6 + listDiscoverableEventsFromContrail, 7 + listEventRecordsFromContrail, 8 + type ActivityCluster 5 9 } from '$lib/contrail'; 10 + import { getSpacesClient } from '$lib/spaces/server/client'; 11 + import { spacesAvailable } from '$lib/spaces/config'; 6 12 import type { PageServerLoad } from './$types'; 7 13 8 - export const load: PageServerLoad = async ({ platform }) => { 9 - const client = getServerClient(platform!.env.DB); 10 - const now = new Date().toISOString(); 14 + const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; 15 + const ACTIVITY_FETCH_LIMIT = 100; 16 + const ACTIVITY_DISPLAY_LIMIT = 10; 17 + 18 + export const load: PageServerLoad = async ({ locals, platform }) => { 19 + const publicClient = getServerClient(platform!.env.DB); 20 + const nowIso = new Date().toISOString(); 21 + 22 + const myEventsPromise = (async () => { 23 + if (!locals.did) return { upcoming: [], past: [] }; 24 + 25 + const client = 26 + locals.client && spacesAvailable() 27 + ? getSpacesClient(locals.client, platform!.env.DB) 28 + : publicClient; 29 + 30 + const cutoff = new Date(Date.now() - SEVEN_DAYS_MS); 31 + const cutoffIso = cutoff.toISOString(); 32 + 33 + const [rsvpResponse, hostingResponse] = await Promise.all([ 34 + client.get('rsvp.atmo.rsvp.listRecords', { 35 + params: { actor: locals.did, hydrateEvent: true, limit: 100 } 36 + }), 37 + listEventRecordsFromContrail(client, { 38 + actor: locals.did, 39 + startsAtMin: cutoffIso, 40 + sort: 'startsAt', 41 + order: 'asc', 42 + limit: 100 43 + }) 44 + ]); 45 + 46 + const rsvpEvents = (rsvpResponse.ok ? (rsvpResponse.data.records ?? []) : []) 47 + .filter((r) => { 48 + const status = r.record?.status; 49 + return status?.endsWith('#going') || status?.endsWith('#interested'); 50 + }) 51 + .flatMap((r) => { 52 + if (!r.event) return []; 53 + const flat = flattenEventRecord(r.event); 54 + return flat ? [flat] : []; 55 + }) 56 + .filter((e) => new Date(e.endsAt || e.startsAt) >= cutoff); 57 + 58 + const hostingEvents = hostingResponse ? flattenEventRecords(hostingResponse.records) : []; 59 + 60 + const seen = new Set<string>(); 61 + const all = [...rsvpEvents, ...hostingEvents].filter((e) => { 62 + if (seen.has(e.uri)) return false; 63 + seen.add(e.uri); 64 + return true; 65 + }); 66 + 67 + const nowMs = Date.now(); 68 + const upcoming = all 69 + .filter((e) => new Date(e.endsAt || e.startsAt).getTime() >= nowMs) 70 + .sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime()); 71 + const past = all 72 + .filter((e) => new Date(e.endsAt || e.startsAt).getTime() < nowMs) 73 + .sort((a, b) => new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime()); 74 + 75 + return { upcoming, past }; 76 + })(); 11 77 12 - const response = await listDiscoverableEventsFromContrail(client, { 13 - startsAtMin: now, 78 + const globalPromise = listDiscoverableEventsFromContrail(publicClient, { 79 + startsAtMin: nowIso, 14 80 rsvpsGoingCountMin: 2, 15 81 hydrateRsvps: 5, 16 82 sort: 'startsAt', ··· 18 84 limit: 20 19 85 }); 20 86 21 - if (!response) return { events: [], handles: {} }; 87 + const recentActivityPromise = (async (): Promise<ActivityCluster[]> => { 88 + const response = await publicClient.get('rsvp.atmo.rsvp.listRecords', { 89 + params: { 90 + hydrateEvent: true, 91 + profiles: true, 92 + limit: ACTIVITY_FETCH_LIMIT 93 + } 94 + }); 95 + if (!response.ok) return []; 96 + 97 + const records = response.data.records ?? []; 98 + const profiles = response.data.profiles ?? []; 99 + const nowMs = Date.now(); 100 + const clusters = new Map<string, ActivityCluster>(); 101 + 102 + for (const r of records) { 103 + const status = r.record?.status; 104 + const isGoing = status?.endsWith('#going'); 105 + const isInterested = status?.endsWith('#interested'); 106 + if (!isGoing && !isInterested) continue; 107 + 108 + if (!r.event) continue; 109 + const flatEvent = flattenEventRecord(r.event); 110 + if (!flatEvent) continue; 111 + 112 + const eventEndMs = new Date(flatEvent.endsAt || flatEvent.startsAt).getTime(); 113 + if (eventEndMs < nowMs) continue; 114 + 115 + const attendee = buildAttendee(r.did, isGoing ? 'going' : 'interested', profiles); 116 + 117 + let cluster = clusters.get(flatEvent.uri); 118 + if (!cluster) { 119 + cluster = { event: flatEvent, attendees: [], latestTimeUs: r.time_us }; 120 + clusters.set(flatEvent.uri, cluster); 121 + } 122 + cluster.attendees.push(attendee); 123 + if (r.time_us > cluster.latestTimeUs) cluster.latestTimeUs = r.time_us; 124 + } 125 + 126 + return Array.from(clusters.values()) 127 + .sort((a, b) => b.latestTimeUs - a.latestTimeUs) 128 + .slice(0, ACTIVITY_DISPLAY_LIMIT); 129 + })(); 130 + 131 + const [myEvents, response, recentActivity] = await Promise.all([ 132 + myEventsPromise, 133 + globalPromise, 134 + recentActivityPromise 135 + ]); 136 + 137 + if (!response) { 138 + return { 139 + events: [], 140 + handles: {}, 141 + myUpcoming: myEvents.upcoming, 142 + myPast: myEvents.past, 143 + recentActivity 144 + }; 145 + } 22 146 23 147 const handles: Record<string, string> = {}; 24 148 for (const p of response.profiles ?? []) { ··· 27 151 28 152 return { 29 153 events: flattenEventRecords(response.records), 30 - handles 154 + handles, 155 + myUpcoming: myEvents.upcoming, 156 + myPast: myEvents.past, 157 + recentActivity 31 158 }; 32 159 };
+100 -10
src/routes/(app)/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import EventCard from '$lib/components/EventCard.svelte'; 3 + import RecentActivity from '$lib/components/RecentActivity.svelte'; 3 4 import { Button } from '@foxui/core'; 4 5 import { user } from '$lib/atproto/auth.svelte'; 5 6 import { atProtoLoginModalState } from '$lib/components/LoginModal.svelte'; 6 7 7 8 let { data } = $props(); 9 + 10 + let hasMyEvents = $derived( 11 + user.isLoggedIn && (data.myUpcoming.length > 0 || data.myPast.length > 0) 12 + ); 8 13 </script> 9 14 10 15 <div class="mx-auto max-w-3xl px-6 py-8 sm:py-12"> 11 16 <div class="mb-32 mt-16 sm:mt-28"> 12 17 <h1 class="text-base-900 dark:text-base-50 text-4xl font-bold sm:text-5xl"> 13 - Go outside and meet people! 18 + Events for the open social web. 14 19 </h1> 15 - {#if user.isLoggedIn} 16 - <Button href="/create" class="mt-5"> 17 - Create Event 18 - </Button> 19 - {:else} 20 - <Button onclick={() => atProtoLoginModalState.show()} class="mt-5"> 21 - Create Event 22 - </Button> 23 - {/if} 20 + <p class="text-base-600 dark:text-base-300 mt-5 max-w-2xl text-lg sm:text-xl"> 21 + Open source. Sign in with Bluesky. Your events stay yours. 22 + </p> 23 + <div class="mt-7 flex flex-wrap items-center gap-x-6 gap-y-3"> 24 + {#if user.isLoggedIn} 25 + <Button href="/create">Create Event</Button> 26 + {:else} 27 + <Button onclick={() => atProtoLoginModalState.show()}>Create Event</Button> 28 + {/if} 29 + <a 30 + href="/events" 31 + class="text-sm font-medium text-accent-600 hover:text-accent-700 dark:text-accent-400 dark:hover:text-accent-300 transition-colors" 32 + > 33 + Browse Events &rarr; 34 + </a> 35 + </div> 24 36 </div> 25 37 38 + {#if hasMyEvents} 39 + <section class="mb-16"> 40 + <div class="mb-8 flex items-baseline justify-between"> 41 + <h2 class="text-base-900 dark:text-base-50 text-xl font-bold">Your events</h2> 42 + <a 43 + href="/calendar" 44 + class="text-sm font-medium text-accent-600 hover:text-accent-700 dark:text-accent-400 dark:hover:text-accent-300 transition-colors" 45 + > 46 + Open calendar &rarr; 47 + </a> 48 + </div> 49 + 50 + <div class="space-y-10"> 51 + {#if data.myUpcoming.length > 0} 52 + <div> 53 + <h3 class="text-base-600 dark:text-base-400 mb-4 text-sm font-medium"> 54 + Upcoming events 55 + </h3> 56 + <div class="grid gap-6 sm:grid-cols-2"> 57 + {#each data.myUpcoming.slice(0, 6) as event (event.uri)} 58 + <EventCard {event} /> 59 + {/each} 60 + </div> 61 + </div> 62 + {/if} 63 + 64 + {#if data.myPast.length > 0} 65 + <div> 66 + <h3 class="text-base-600 dark:text-base-400 mb-4 text-sm font-medium"> 67 + Recent past events 68 + </h3> 69 + <div class="grid gap-6 sm:grid-cols-2"> 70 + {#each data.myPast.slice(0, 4) as event (event.uri)} 71 + <EventCard {event} /> 72 + {/each} 73 + </div> 74 + </div> 75 + {/if} 76 + </div> 77 + </section> 78 + {/if} 79 + 26 80 <div class="mb-8 flex items-baseline justify-between"> 27 81 <h2 class="text-base-900 dark:text-base-50 text-xl font-bold">Upcoming Events</h2> 28 82 <a ··· 41 95 <EventCard {event} actor={data.handles[event.did]} /> 42 96 {/each} 43 97 </div> 98 + {/if} 99 + 100 + {#if data.recentActivity.length > 0} 101 + <section class="mt-16"> 102 + <h2 class="text-base-900 dark:text-base-50 mb-4 text-xl font-bold">Recent activity</h2> 103 + <RecentActivity activities={data.recentActivity} /> 104 + </section> 105 + {/if} 106 + 107 + {#if !user.isLoggedIn} 108 + <section class="border-base-200 dark:border-base-800 mt-20 grid gap-10 border-t pt-12 sm:grid-cols-3 sm:gap-8"> 109 + <div> 110 + <h3 class="text-base-900 dark:text-base-50 mb-2 text-base font-semibold"> 111 + Sign in with Bluesky. 112 + </h3> 113 + <p class="text-base-600 dark:text-base-400 text-sm"> 114 + Your network is already here. RSVP with the people you actually follow. 115 + </p> 116 + </div> 117 + <div> 118 + <h3 class="text-base-900 dark:text-base-50 mb-2 text-base font-semibold"> 119 + Works across apps. 120 + </h3> 121 + <p class="text-base-600 dark:text-base-400 text-sm"> 122 + See events from any app on the open social web. Your RSVPs travel with you. 123 + </p> 124 + </div> 125 + <div> 126 + <h3 class="text-base-900 dark:text-base-50 mb-2 text-base font-semibold"> 127 + Your stuff stays yours. 128 + </h3> 129 + <p class="text-base-600 dark:text-base-400 text-sm"> 130 + Events live on your account, not ours. atmo.rsvp is just a view — take everything anywhere. 131 + </p> 132 + </div> 133 + </section> 44 134 {/if} 45 135 46 136 <footer class="text-base-500 dark:text-base-400 mt-24 text-center text-sm">
+16 -22
src/routes/(app)/calendar/+page.server.ts
··· 10 10 11 11 export const load: PageServerLoad = async ({ locals, platform }) => { 12 12 if (!locals.did) { 13 - return { events: [], loggedIn: false }; 13 + return { upcoming: [], past: [], loggedIn: false }; 14 14 } 15 - // Authenticated + spaces configured → use the service-auth client so the 16 - // server unions public events with events from every space the user is in. 17 - // Falls back to the unauthenticated client otherwise (public-only). 18 15 const client = 19 16 locals.client && spacesAvailable() 20 17 ? getSpacesClient(locals.client, platform!.env.DB) 21 18 : getServerClient(platform!.env.DB); 22 19 23 - const now = new Date().toISOString(); 24 - 25 20 const [rsvpResponse, hostingResponse] = await Promise.all([ 26 21 client.get('rsvp.atmo.rsvp.listRecords', { 27 22 params: { actor: locals.did, hydrateEvent: true, limit: 100 } 28 23 }), 29 24 listEventRecordsFromContrail(client, { 30 25 actor: locals.did, 31 - startsAtMin: now, 32 26 sort: 'startsAt', 33 - order: 'asc', 27 + order: 'desc', 34 28 limit: 100 35 29 }) 36 30 ]); 37 31 38 - const nowDate = new Date(); 39 - 40 - // Events from RSVPs 41 32 const rsvpEvents = (rsvpResponse.ok ? (rsvpResponse.data.records ?? []) : []) 42 33 .filter((r) => { 43 34 const status = r.record?.status; ··· 47 38 if (!r.event) return []; 48 39 const flat = flattenEventRecord(r.event); 49 40 return flat ? [flat] : []; 50 - }) 51 - .filter((e) => new Date(e.endsAt || e.startsAt) >= nowDate); 41 + }); 52 42 53 - // Events the user is hosting 54 43 const hostingEvents = hostingResponse ? flattenEventRecords(hostingResponse.records) : []; 55 44 56 - // Merge and deduplicate 57 45 const seen = new Set<string>(); 58 - const events = [...rsvpEvents, ...hostingEvents] 59 - .filter((e) => { 60 - if (seen.has(e.uri)) return false; 61 - seen.add(e.uri); 62 - return true; 63 - }) 46 + const all = [...rsvpEvents, ...hostingEvents].filter((e) => { 47 + if (seen.has(e.uri)) return false; 48 + seen.add(e.uri); 49 + return true; 50 + }); 51 + 52 + const nowMs = Date.now(); 53 + const upcoming = all 54 + .filter((e) => new Date(e.endsAt || e.startsAt).getTime() >= nowMs) 64 55 .sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime()); 56 + const past = all 57 + .filter((e) => new Date(e.endsAt || e.startsAt).getTime() < nowMs) 58 + .sort((a, b) => new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime()); 65 59 66 - return { events, loggedIn: true, did: locals.did }; 60 + return { upcoming, past, loggedIn: true, did: locals.did }; 67 61 };
+41 -17
src/routes/(app)/calendar/+page.svelte
··· 13 13 user.profile?.handle ? `https://atmo.rsvp/p/${user.profile.handle}/calendar.ics` : '' 14 14 ); 15 15 16 + let hasEvents = $derived(data.upcoming.length > 0 || data.past.length > 0); 17 + 16 18 async function copyAndShowModal() { 17 19 if (!calendarUrl) return; 18 20 try { ··· 35 37 <div class="mx-auto max-w-3xl px-6 py-8 sm:py-12"> 36 38 <div class="mb-2 flex items-center justify-between"> 37 39 <h1 class="text-base-900 dark:text-base-50 text-2xl font-bold">Calendar</h1> 38 - {#if data.loggedIn && data.events.length > 0} 40 + {#if data.loggedIn && hasEvents} 39 41 <Button variant="secondary" onclick={copyAndShowModal}> 40 42 <svg 41 43 xmlns="http://www.w3.org/2000/svg" ··· 53 55 </Button> 54 56 {/if} 55 57 </div> 56 - <h1 class="text-base-700 dark:text-base-300 mb-8 mt-4 text-sm"> 57 - Upcoming events you're hosting, attending or interested in 58 - </h1> 58 + <p class="text-base-700 dark:text-base-300 mb-8 mt-4 text-sm"> 59 + Events you're hosting, attending or interested in 60 + </p> 59 61 60 62 {#if !data.loggedIn} 61 63 <div ··· 64 66 <p class="text-base-600 dark:text-base-400 mb-4">Log in to see your events</p> 65 67 <Button onclick={() => atProtoLoginModalState.show()}>Login</Button> 66 68 </div> 67 - {:else if data.events.length === 0} 69 + {:else if !hasEvents} 68 70 <div 69 71 class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-950/50 rounded-2xl border p-8 text-center" 70 72 > 71 73 <p class="text-base-600 dark:text-base-400 text-center text-sm"> 72 - No upcoming events on your calendar. 74 + No events on your calendar. 73 75 </p> 74 76 <div class="mt-6 flex justify-center gap-3"> 75 77 <Button href="/">Join events</Button> ··· 77 79 </div> 78 80 </div> 79 81 {:else} 80 - <div class="grid gap-6 sm:grid-cols-2"> 81 - {#each data.events as event (event.uri)} 82 - <EventCard {event} /> 83 - {/each} 82 + <div class="space-y-10"> 83 + {#if data.upcoming.length > 0} 84 + <div> 85 + <h3 class="text-base-600 dark:text-base-400 mb-4 text-sm font-medium"> 86 + Upcoming events 87 + </h3> 88 + <div class="grid gap-6 sm:grid-cols-2"> 89 + {#each data.upcoming as event (event.uri)} 90 + <EventCard {event} /> 91 + {/each} 92 + </div> 93 + </div> 94 + {/if} 95 + 96 + {#if data.past.length > 0} 97 + <div> 98 + <h3 class="text-base-600 dark:text-base-400 mb-4 text-sm font-medium"> 99 + Past events 100 + </h3> 101 + <div class="grid gap-6 sm:grid-cols-2"> 102 + {#each data.past as event (event.uri)} 103 + <EventCard {event} /> 104 + {/each} 105 + </div> 106 + </div> 107 + {/if} 84 108 </div> 85 109 86 110 <Modal bind:open={calendarModalOpen}> ··· 89 113 Add to your calendar 90 114 </h2> 91 115 <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. 116 + Subscribe to your events calendar using the URL below. Your calendar will stay in 117 + sync automatically as you RSVP to new events. 94 118 </p> 95 119 96 120 <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" 121 + class="border-base-200 dark:border-base-700 bg-base-50 dark:bg-base-900 hover:bg-base-100 dark:hover:bg-base-800 flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 transition-colors" 98 122 onclick={async () => { 99 123 try { 100 124 await navigator.clipboard.writeText(calendarUrl); 101 125 copied = true; 102 - setTimeout(() => { copied = false; }, 3000); 126 + setTimeout(() => { 127 + copied = false; 128 + }, 3000); 103 129 } catch { 104 130 // ignore 105 131 } ··· 135 161 class="text-base-600 dark:text-base-400 mt-1 list-inside list-decimal space-y-0.5 text-sm" 136 162 > 137 163 <li>Open Google Calendar in your browser</li> 138 - <li> 139 - Click the + next to "Other calendars" in the sidebar 140 - </li> 164 + <li>Click the + next to "Other calendars" in the sidebar</li> 141 165 <li>Select "From URL"</li> 142 166 <li>Paste the URL above and click "Add calendar"</li> 143 167 </ol>
+3 -1
src/routes/(app)/events/+page.server.ts
··· 11 11 const client = getServerClient(platform!.env.DB); 12 12 const now = new Date().toISOString(); 13 13 const cursor = url.searchParams.get('cursor') ?? undefined; 14 + const isPopular = url.searchParams.get('filter') !== 'all'; 14 15 15 16 const response = await listDiscoverableEventsFromContrail(client, { 16 17 startsAtMin: now, ··· 18 19 sort: 'startsAt', 19 20 order: 'asc', 20 21 limit: PAGE_SIZE, 21 - cursor 22 + cursor, 23 + ...(isPopular ? { rsvpsGoingCountMin: 2 } : {}) 22 24 }); 23 25 24 26 if (!response) return { events: [], handles: {}, cursor: null };
+47 -5
src/routes/(app)/events/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import EventList from '$lib/components/EventList.svelte'; 3 + import { ToggleGroup, ToggleGroupItem } from '@foxui/core'; 4 + import { goto } from '$app/navigation'; 5 + import { page } from '$app/state'; 3 6 4 7 let { data } = $props(); 5 8 6 - const fetchParams = { 9 + let filter = $derived(page.url.searchParams.get('filter') === 'all' ? 'all' : 'popular'); 10 + 11 + let fetchParams = $derived({ 7 12 startsAtMin: new Date().toISOString(), 8 13 profiles: 'true', 9 14 sort: 'startsAt', 10 15 order: 'asc', 11 - limit: '20' 12 - }; 16 + limit: '20', 17 + ...(filter === 'popular' ? { rsvpsGoingCountMin: '2' } : {}) 18 + }); 19 + 20 + function setFilter(val: string) { 21 + const url = new URL(page.url); 22 + if (val === 'all') url.searchParams.set('filter', 'all'); 23 + else url.searchParams.delete('filter'); 24 + url.searchParams.delete('cursor'); 25 + goto(url, { keepFocus: true, noScroll: true }); 26 + } 13 27 </script> 14 28 15 29 <svelte:head> ··· 17 31 </svelte:head> 18 32 19 33 <div class="mx-auto max-w-3xl px-6 py-8 sm:py-12"> 20 - <h1 class="text-base-900 dark:text-base-50 mb-8 text-2xl font-bold">Upcoming Events</h1> 34 + <div class="mb-8 flex flex-wrap items-center justify-between gap-4"> 35 + <h1 class="text-base-900 dark:text-base-50 text-2xl font-bold">Upcoming Events</h1> 36 + <ToggleGroup 37 + type="single" 38 + bind:value={ 39 + () => filter, 40 + (val) => { 41 + if (val) setFilter(val); 42 + } 43 + } 44 + class="w-fit" 45 + size="xs" 46 + > 47 + <ToggleGroupItem value="popular">Popular</ToggleGroupItem> 48 + <ToggleGroupItem value="all">All</ToggleGroupItem> 49 + </ToggleGroup> 50 + </div> 21 51 22 52 {#if data.events.length === 0} 23 - <p class="text-base-500 text-center text-lg">No upcoming events found.</p> 53 + <p class="text-base-500 text-center text-lg"> 54 + {#if filter === 'popular'} 55 + No popular events right now. <button 56 + type="button" 57 + onclick={() => setFilter('all')} 58 + class="text-accent-600 hover:text-accent-700 dark:text-accent-400 dark:hover:text-accent-300 font-medium underline-offset-2 hover:underline" 59 + > 60 + See all events &rarr; 61 + </button> 62 + {:else} 63 + No upcoming events found. 64 + {/if} 65 + </p> 24 66 {:else} 25 67 <EventList events={data.events} cursor={data.cursor} handles={data.handles} {fetchParams} /> 26 68 {/if}