atmo.rsvp
1<script lang="ts">
2 import type { AttendeeInfo } from '$lib/contrail';
3 import { Avatar as FoxAvatar } from '@foxui/core';
4 import { scale } from 'svelte/transition';
5 import { flip } from 'svelte/animate';
6 import { Modal } from '@foxui/core';
7
8 let {
9 going = [],
10 interested = [],
11 goingCount: initialGoingCount = going.length,
12 interestedCount: initialInterestedCount = interested.length
13 }: {
14 going?: AttendeeInfo[];
15 interested?: AttendeeInfo[];
16 goingCount?: number;
17 interestedCount?: number;
18 } = $props();
19
20 let goingCountOverride: number | null = $state(null);
21 let interestedCountOverride: number | null = $state(null);
22 let goingAttendeesOverride: AttendeeInfo[] | null = $state(null);
23 let interestedAttendeesOverride: AttendeeInfo[] | null = $state(null);
24
25 let modalOpen = $state(false);
26 let modalGroup: 'going' | 'interested' = $state('going');
27
28 const MAX_AVATARS = 18;
29
30 let goingCount = $derived(goingCountOverride ?? initialGoingCount);
31 let interestedCount = $derived(interestedCountOverride ?? initialInterestedCount);
32 let goingAttendees = $derived(goingAttendeesOverride ?? going);
33 let interestedAttendees = $derived(interestedAttendeesOverride ?? interested);
34
35 let totalCount = $derived(goingCount + interestedCount);
36
37 let goingDisplay = $derived(goingAttendees.slice(0, MAX_AVATARS));
38 let goingOverflow = $derived(goingCount - goingDisplay.length);
39
40 let interestedDisplay = $derived(interestedAttendees.slice(0, MAX_AVATARS));
41 let interestedOverflow = $derived(interestedCount - interestedDisplay.length);
42
43 let modalAttendees = $derived(modalGroup === 'going' ? goingAttendees : interestedAttendees);
44 let modalTitle = $derived(modalGroup === 'going' ? 'Going' : 'Interested');
45
46 function openModal(group: 'going' | 'interested') {
47 modalGroup = group;
48 modalOpen = true;
49 }
50
51 export function addAttendee(attendee: AttendeeInfo) {
52 const nextGoing = goingAttendees.filter((a) => a.did !== attendee.did);
53 const nextInterested = interestedAttendees.filter((a) => a.did !== attendee.did);
54
55 // Remove from both lists first (in case of status change)
56 if (attendee.status === 'going') {
57 goingAttendeesOverride = [attendee, ...nextGoing];
58 interestedAttendeesOverride = nextInterested;
59 goingCountOverride = goingAttendeesOverride.length;
60 interestedCountOverride = interestedAttendeesOverride.length;
61 } else if (attendee.status === 'interested') {
62 goingAttendeesOverride = nextGoing;
63 interestedAttendeesOverride = [attendee, ...nextInterested];
64 goingCountOverride = goingAttendeesOverride.length;
65 interestedCountOverride = interestedAttendeesOverride.length;
66 }
67 }
68
69 function thumbnail(url: string | undefined) {
70 return url?.replace('/avatar/', '/avatar_thumbnail/');
71 }
72
73 export function removeAttendee(did: string) {
74 const wasGoing = goingAttendees.some((a) => a.did === did);
75 const wasInterested = interestedAttendees.some((a) => a.did === did);
76 goingAttendeesOverride = goingAttendees.filter((a) => a.did !== did);
77 interestedAttendeesOverride = interestedAttendees.filter((a) => a.did !== did);
78 if (wasGoing) goingCountOverride = goingAttendeesOverride.length;
79 if (wasInterested) interestedCountOverride = interestedAttendeesOverride.length;
80 }
81</script>
82
83{#if totalCount > 0}
84 <div class="mb-2">
85 {#if goingCount > 0}
86 <button
87 type="button"
88 class="hover:bg-base-100 dark:hover:bg-base-800/50 -mx-2 block w-full cursor-pointer rounded-xl px-2 py-2 text-left transition-colors"
89 onclick={() => openModal('going')}
90 >
91 <p class="text-base-900 dark:text-base-50 mb-2 text-sm">
92 <span class="font-bold">{goingCount}</span>
93 <span
94 class="text-base-500 dark:text-base-400 text-xs font-semibold tracking-wider uppercase"
95 >Going</span
96 >
97 </p>
98 <div class="flex items-center">
99 <div class="flex flex-wrap -space-y-2 -space-x-4 pr-4">
100 {#each goingDisplay as person (person.did)}
101 <div
102 animate:flip={{ duration: 300 }}
103 in:scale={{ duration: 300, start: 0.5 }}
104 out:scale={{ duration: 200, start: 0.5 }}
105 >
106 <FoxAvatar
107 src={thumbnail(person.avatar)}
108 alt={person.name}
109 class="border-base-100 dark:border-base-900 size-12 border-2"
110 />
111 </div>
112 {/each}
113 {#if goingOverflow > 0}
114 <span
115 class="bg-base-200 dark:bg-base-800 text-base-950 dark:text-base-100 border-base-100 dark:border-base-900 z-10 inline-flex size-12 items-center justify-center rounded-full border-2 text-sm font-semibold"
116 >
117 +{goingOverflow}
118 </span>
119 {/if}
120 </div>
121 </div>
122 </button>
123 {/if}
124
125 {#if interestedCount > 0}
126 <button
127 type="button"
128 class="hover:bg-base-100 dark:hover:bg-base-800/50 -mx-2 mt-4 block w-full cursor-pointer rounded-xl px-2 py-2 text-left transition-colors"
129 onclick={() => openModal('interested')}
130 >
131 <p class="text-base-900 dark:text-base-50 mb-2 text-sm">
132 <span class="font-bold">{interestedCount}</span>
133 <span
134 class="text-base-500 dark:text-base-400 text-xs font-semibold tracking-wider uppercase"
135 >Interested</span
136 >
137 </p>
138 <div class="flex items-center">
139 <div class="flex flex-wrap -space-y-2 -space-x-4 pr-4">
140 {#each interestedDisplay as person (person.did)}
141 <div
142 animate:flip={{ duration: 300 }}
143 in:scale={{ duration: 300, start: 0.5 }}
144 out:scale={{ duration: 200, start: 0.5 }}
145 >
146 <FoxAvatar
147 src={thumbnail(person.avatar)}
148 alt={person.name}
149 class="border-base-100 dark:border-base-900 size-12 border-2"
150 />
151 </div>
152 {/each}
153 {#if interestedOverflow > 0}
154 <span
155 class="bg-base-200 dark:bg-base-800 text-base-950 dark:text-base-100 border-base-100 dark:border-base-900 z-10 inline-flex size-12 items-center justify-center rounded-full border-2 text-sm font-semibold"
156 >
157 +{interestedOverflow}
158 </span>
159 {/if}
160 </div>
161 </div>
162 </button>
163 {/if}
164 </div>
165{/if}
166
167<Modal
168 bind:open={modalOpen}
169 closeButton
170 onOpenAutoFocus={(e: Event) => e.preventDefault()}
171 class="p-0"
172>
173 <p class="text-base-900 dark:text-base-50 px-4 pt-4 text-lg font-semibold">
174 {modalTitle}
175 <span class="text-base-500 dark:text-base-400 text-sm font-normal">
176 ({modalAttendees.length})
177 </span>
178 </p>
179 <div
180 class="dark:bg-base-900/50 bg-base-200/30 mx-4 mb-4 max-h-80 space-y-1 overflow-y-auto rounded-xl p-2"
181 >
182 {#each modalAttendees as person (person.did)}
183 <a
184 href={person.url}
185 target={person.url?.startsWith('/') ? undefined : '_blank'}
186 rel={person.url?.startsWith('/') ? undefined : 'noopener noreferrer'}
187 class="hover:bg-base-200 dark:hover:bg-base-900 flex items-center gap-3 rounded-xl px-2 py-2 transition-colors"
188 >
189 <FoxAvatar src={thumbnail(person.avatar)} alt={person.name} class="size-10 shrink-0" />
190 <div class="min-w-0">
191 <p class="text-base-900 dark:text-base-50 truncate text-sm font-medium">
192 {person.name}
193 </p>
194 {#if person.handle}
195 <p class="text-base-500 dark:text-base-400 truncate text-xs">
196 @{person.handle}
197 </p>
198 {/if}
199 </div>
200 </a>
201 {/each}
202 </div>
203</Modal>