tangled
alpha
login
or
join now
flo-bit.dev
/
blento
21
fork
atom
your personal website on atproto - mirror
blento.app
21
fork
atom
overview
issues
pulls
pipelines
add event cards
Florian
1 week ago
af84ff8f
d68501ad
+540
-1
6 changed files
expand all
collapse all
unified
split
src
lib
cards
index.ts
social
UpcomingEventsCard
UpcomingEventsCard.svelte
index.ts
UpcomingRsvpsCard
UpcomingRsvpsCard.svelte
index.ts
website
Account.svelte
+4
src/lib/cards/index.ts
···
27
27
import { StandardSiteDocumentListCardDefinition } from './content/StandardSiteDocumentListCard';
28
28
import { StatusphereCardDefinition } from './media/StatusphereCard';
29
29
import { EventCardDefinition } from './social/EventCard';
30
30
+
import { UpcomingEventsCardDefinition } from './social/UpcomingEventsCard';
31
31
+
import { UpcomingRsvpsCardDefinition } from './social/UpcomingRsvpsCard';
30
32
import { VCardCardDefinition } from './social/VCardCard';
31
33
import { DrawCardDefinition } from './visual/DrawCard';
32
34
import { TimerCardDefinition } from './utilities/TimerCard';
···
83
85
StandardSiteDocumentListCardDefinition,
84
86
StatusphereCardDefinition,
85
87
EventCardDefinition,
88
88
+
UpcomingEventsCardDefinition,
89
89
+
UpcomingRsvpsCardDefinition,
86
90
VCardCardDefinition,
87
91
DrawCardDefinition,
88
92
TimerCardDefinition,
+265
src/lib/cards/social/UpcomingEventsCard/UpcomingEventsCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { onMount } from 'svelte';
3
3
+
import { Badge } from '@foxui/core';
4
4
+
import {
5
5
+
getAdditionalUserData,
6
6
+
getDidContext,
7
7
+
getHandleContext,
8
8
+
getIsMobile
9
9
+
} from '$lib/website/context';
10
10
+
import type { ContentComponentProps } from '../../types';
11
11
+
import { CardDefinitionsByType } from '../..';
12
12
+
import type { EventData } from '../EventCard';
13
13
+
import { user } from '$lib/atproto';
14
14
+
import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte';
15
15
+
import * as TID from '@atcute/tid';
16
16
+
17
17
+
let { item }: ContentComponentProps = $props();
18
18
+
19
19
+
let isMobile = getIsMobile();
20
20
+
let isLoaded = $state(false);
21
21
+
const data = getAdditionalUserData();
22
22
+
const did = getDidContext();
23
23
+
const handle = getHandleContext();
24
24
+
25
25
+
type EventWithRkey = EventData & { rkey: string };
26
26
+
27
27
+
// svelte-ignore state_referenced_locally
28
28
+
let events = $state<EventWithRkey[]>(
29
29
+
((data['upcomingEvents'] as { events?: EventWithRkey[] })?.events ?? []) as EventWithRkey[]
30
30
+
);
31
31
+
32
32
+
onMount(async () => {
33
33
+
try {
34
34
+
const loaded = await CardDefinitionsByType[item.cardType]?.loadData?.([item], {
35
35
+
did,
36
36
+
handle
37
37
+
});
38
38
+
const result = loaded as { events?: EventWithRkey[] } | undefined;
39
39
+
const freshEvents = result?.events ?? [];
40
40
+
41
41
+
if (freshEvents.length > 0) {
42
42
+
events = freshEvents;
43
43
+
}
44
44
+
45
45
+
data['upcomingEvents'] = { events };
46
46
+
} catch (e) {
47
47
+
console.error('Failed to load upcoming events', e);
48
48
+
}
49
49
+
50
50
+
isLoaded = true;
51
51
+
});
52
52
+
53
53
+
function formatDate(dateStr: string): string {
54
54
+
const date = new Date(dateStr);
55
55
+
return date.toLocaleDateString('en-US', {
56
56
+
weekday: 'short',
57
57
+
month: 'short',
58
58
+
day: 'numeric'
59
59
+
});
60
60
+
}
61
61
+
62
62
+
function formatTime(dateStr: string): string {
63
63
+
const date = new Date(dateStr);
64
64
+
return date.toLocaleTimeString('en-US', {
65
65
+
hour: 'numeric',
66
66
+
minute: '2-digit'
67
67
+
});
68
68
+
}
69
69
+
70
70
+
function getModeLabel(mode: string): string {
71
71
+
if (mode.includes('virtual')) return 'Virtual';
72
72
+
if (mode.includes('hybrid')) return 'Hybrid';
73
73
+
if (mode.includes('inperson')) return 'In-Person';
74
74
+
return 'Event';
75
75
+
}
76
76
+
77
77
+
function getModeColor(mode: string): string {
78
78
+
if (mode.includes('virtual')) return 'blue';
79
79
+
if (mode.includes('hybrid')) return 'purple';
80
80
+
if (mode.includes('inperson')) return 'green';
81
81
+
return 'gray';
82
82
+
}
83
83
+
84
84
+
let isOwner = $derived(user.isLoggedIn && user.did === did);
85
85
+
let isRefreshing = $state(false);
86
86
+
87
87
+
async function refreshEvents() {
88
88
+
isRefreshing = true;
89
89
+
try {
90
90
+
const loaded = await CardDefinitionsByType[item.cardType]?.loadData?.([item], {
91
91
+
did,
92
92
+
handle
93
93
+
});
94
94
+
const result = loaded as { events?: EventWithRkey[] } | undefined;
95
95
+
const freshEvents = result?.events ?? [];
96
96
+
events = freshEvents;
97
97
+
data['upcomingEvents'] = { events };
98
98
+
} catch (e) {
99
99
+
console.error('Failed to refresh events', e);
100
100
+
}
101
101
+
isRefreshing = false;
102
102
+
}
103
103
+
</script>
104
104
+
105
105
+
<div class="flex h-full flex-col overflow-hidden p-4">
106
106
+
<!-- Header row -->
107
107
+
<div class="mb-3 flex items-center justify-between">
108
108
+
<div class="flex items-center gap-2">
109
109
+
<div
110
110
+
class="bg-base-100 border-base-300 accent:bg-accent-100/50 accent:border-accent-200 dark:border-base-800 dark:bg-base-900 flex size-8 shrink-0 items-center justify-center rounded-xl border"
111
111
+
>
112
112
+
<svg
113
113
+
xmlns="http://www.w3.org/2000/svg"
114
114
+
fill="none"
115
115
+
viewBox="0 0 24 24"
116
116
+
stroke-width="1.5"
117
117
+
stroke="currentColor"
118
118
+
class="size-4"
119
119
+
>
120
120
+
<path
121
121
+
stroke-linecap="round"
122
122
+
stroke-linejoin="round"
123
123
+
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"
124
124
+
/>
125
125
+
</svg>
126
126
+
</div>
127
127
+
<span class="text-base-900 dark:text-base-50 text-sm font-semibold">Events</span>
128
128
+
</div>
129
129
+
{#if isOwner}
130
130
+
<div class="flex items-center gap-1">
131
131
+
<button
132
132
+
onclick={refreshEvents}
133
133
+
disabled={isRefreshing}
134
134
+
title="Refresh events"
135
135
+
class="bg-base-100 hover:bg-base-200 dark:bg-base-800 dark:hover:bg-base-700 accent:bg-accent-400/30 accent:hover:bg-accent-400/50 text-base-700 dark:text-base-300 z-50 flex size-7 items-center justify-center rounded-lg transition-colors disabled:opacity-50"
136
136
+
>
137
137
+
<svg
138
138
+
xmlns="http://www.w3.org/2000/svg"
139
139
+
fill="none"
140
140
+
viewBox="0 0 24 24"
141
141
+
stroke-width="2"
142
142
+
stroke="currentColor"
143
143
+
class="size-4"
144
144
+
class:animate-spin={isRefreshing}
145
145
+
>
146
146
+
<path
147
147
+
stroke-linecap="round"
148
148
+
stroke-linejoin="round"
149
149
+
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.992 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182M20.016 4.657v4.992"
150
150
+
/>
151
151
+
</svg>
152
152
+
</button>
153
153
+
<a
154
154
+
href="/{handle}/events/{TID.now()}/edit"
155
155
+
target="_blank"
156
156
+
title="Create new event"
157
157
+
class="bg-base-100 hover:bg-base-200 dark:bg-base-800 dark:hover:bg-base-700 accent:bg-accent-400/30 accent:hover:bg-accent-400/50 text-base-700 dark:text-base-300 z-50 flex size-7 items-center justify-center rounded-lg transition-colors"
158
158
+
>
159
159
+
<svg
160
160
+
xmlns="http://www.w3.org/2000/svg"
161
161
+
fill="none"
162
162
+
viewBox="0 0 24 24"
163
163
+
stroke-width="2"
164
164
+
stroke="currentColor"
165
165
+
class="size-4"
166
166
+
>
167
167
+
<path
168
168
+
stroke-linecap="round"
169
169
+
stroke-linejoin="round"
170
170
+
d="M12 4.5v15m7.5-7.5h-15"
171
171
+
/>
172
172
+
</svg>
173
173
+
</a>
174
174
+
</div>
175
175
+
{/if}
176
176
+
</div>
177
177
+
178
178
+
<!-- Scrollable list -->
179
179
+
<div class="flex-1 overflow-y-auto">
180
180
+
{#if events.length > 0}
181
181
+
<div class="flex flex-col gap-2">
182
182
+
{#each events as event (event.rkey)}
183
183
+
<a
184
184
+
href="https://blento.app/{did}/events/{event.rkey}"
185
185
+
target="_blank"
186
186
+
class="hover:bg-base-100 dark:hover:bg-base-800 accent:hover:bg-accent-400/20 flex flex-col gap-1 rounded-lg p-2 transition-colors"
187
187
+
use:qrOverlay={{ context: { title: event.name } }}
188
188
+
>
189
189
+
<div class="flex items-center gap-2">
190
190
+
<span class="text-base-900 dark:text-base-50 line-clamp-1 flex-1 text-sm font-medium"
191
191
+
>{event.name}</span
192
192
+
>
193
193
+
<Badge size="sm" color={getModeColor(event.mode)}>
194
194
+
<span class="accent:text-base-900">{getModeLabel(event.mode)}</span>
195
195
+
</Badge>
196
196
+
</div>
197
197
+
<div
198
198
+
class="text-base-500 dark:text-base-400 accent:text-base-800 flex items-center gap-1 text-xs"
199
199
+
>
200
200
+
<svg
201
201
+
xmlns="http://www.w3.org/2000/svg"
202
202
+
fill="none"
203
203
+
viewBox="0 0 24 24"
204
204
+
stroke-width="1.5"
205
205
+
stroke="currentColor"
206
206
+
class="size-3"
207
207
+
>
208
208
+
<path
209
209
+
stroke-linecap="round"
210
210
+
stroke-linejoin="round"
211
211
+
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
212
212
+
/>
213
213
+
</svg>
214
214
+
<span>{formatDate(event.startsAt)} at {formatTime(event.startsAt)}</span>
215
215
+
</div>
216
216
+
{#if event.locations && event.locations.length > 0}
217
217
+
{@const loc = event.locations[0]?.address}
218
218
+
{#if loc}
219
219
+
{@const parts = [loc.locality, loc.region, loc.country].filter(Boolean)}
220
220
+
{#if parts.length > 0}
221
221
+
<div
222
222
+
class="text-base-500 dark:text-base-400 accent:text-base-800 flex items-center gap-1 text-xs"
223
223
+
>
224
224
+
<svg
225
225
+
xmlns="http://www.w3.org/2000/svg"
226
226
+
fill="none"
227
227
+
viewBox="0 0 24 24"
228
228
+
stroke-width="1.5"
229
229
+
stroke="currentColor"
230
230
+
class="size-3 shrink-0"
231
231
+
>
232
232
+
<path
233
233
+
stroke-linecap="round"
234
234
+
stroke-linejoin="round"
235
235
+
d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
236
236
+
/>
237
237
+
<path
238
238
+
stroke-linecap="round"
239
239
+
stroke-linejoin="round"
240
240
+
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z"
241
241
+
/>
242
242
+
</svg>
243
243
+
<span class="truncate">{parts.join(', ')}</span>
244
244
+
</div>
245
245
+
{/if}
246
246
+
{/if}
247
247
+
{/if}
248
248
+
</a>
249
249
+
{/each}
250
250
+
</div>
251
251
+
{:else if isLoaded}
252
252
+
<div
253
253
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm"
254
254
+
>
255
255
+
No upcoming events
256
256
+
</div>
257
257
+
{:else}
258
258
+
<div
259
259
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm"
260
260
+
>
261
261
+
Loading events...
262
262
+
</div>
263
263
+
{/if}
264
264
+
</div>
265
265
+
</div>
+52
src/lib/cards/social/UpcomingEventsCard/index.ts
···
1
1
+
import { listRecords } from '$lib/atproto';
2
2
+
import type { CardDefinition } from '../../types';
3
3
+
import UpcomingEventsCard from './UpcomingEventsCard.svelte';
4
4
+
import type { Did } from '@atcute/lexicons';
5
5
+
import type { EventData } from '../EventCard';
6
6
+
7
7
+
const EVENT_COLLECTION = 'community.lexicon.calendar.event';
8
8
+
9
9
+
export const UpcomingEventsCardDefinition = {
10
10
+
type: 'upcomingEvents',
11
11
+
contentComponent: UpcomingEventsCard,
12
12
+
createNew: (card) => {
13
13
+
card.w = 4;
14
14
+
card.h = 4;
15
15
+
card.mobileW = 8;
16
16
+
card.mobileH = 6;
17
17
+
},
18
18
+
minW: 2,
19
19
+
minH: 3,
20
20
+
21
21
+
loadData: async (_items, { did }) => {
22
22
+
const records = await listRecords({
23
23
+
did: did as Did,
24
24
+
collection: EVENT_COLLECTION,
25
25
+
limit: 100
26
26
+
});
27
27
+
28
28
+
const now = new Date();
29
29
+
const events: Array<EventData & { rkey: string }> = [];
30
30
+
31
31
+
for (const record of records) {
32
32
+
const event = record.value as EventData;
33
33
+
const endsAt = event.endsAt ? new Date(event.endsAt) : null;
34
34
+
const startsAt = new Date(event.startsAt);
35
35
+
36
36
+
if ((endsAt && endsAt >= now) || (!endsAt && startsAt >= now)) {
37
37
+
const uri = record.uri as string;
38
38
+
const rkey = uri.split('/').pop() || '';
39
39
+
events.push({ ...event, rkey });
40
40
+
}
41
41
+
}
42
42
+
43
43
+
events.sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime());
44
44
+
45
45
+
return { events };
46
46
+
},
47
47
+
48
48
+
name: 'Upcoming Events',
49
49
+
keywords: ['events', 'hosting', 'calendar', 'upcoming'],
50
50
+
groups: ['Social'],
51
51
+
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>`
52
52
+
} as CardDefinition & { type: 'upcomingEvents' };
+177
src/lib/cards/social/UpcomingRsvpsCard/UpcomingRsvpsCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { onMount } from 'svelte';
3
3
+
import { Badge } from '@foxui/core';
4
4
+
import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context';
5
5
+
import type { ContentComponentProps } from '../../types';
6
6
+
import { CardDefinitionsByType } from '../..';
7
7
+
import type { ResolvedRsvp } from '$lib/events/fetch-attendees';
8
8
+
import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte';
9
9
+
10
10
+
let { item }: ContentComponentProps = $props();
11
11
+
12
12
+
let isLoaded = $state(false);
13
13
+
const data = getAdditionalUserData();
14
14
+
const did = getDidContext();
15
15
+
const handle = getHandleContext();
16
16
+
17
17
+
// svelte-ignore state_referenced_locally
18
18
+
let rsvps = $state<ResolvedRsvp[]>(
19
19
+
((data['upcomingRsvps'] as { rsvps?: ResolvedRsvp[] })?.rsvps ?? []) as ResolvedRsvp[]
20
20
+
);
21
21
+
22
22
+
onMount(async () => {
23
23
+
try {
24
24
+
const loaded = await CardDefinitionsByType[item.cardType]?.loadData?.([item], {
25
25
+
did,
26
26
+
handle
27
27
+
});
28
28
+
const result = loaded as { rsvps?: ResolvedRsvp[] } | undefined;
29
29
+
const freshRsvps = result?.rsvps ?? [];
30
30
+
31
31
+
if (freshRsvps.length > 0) {
32
32
+
rsvps = freshRsvps;
33
33
+
}
34
34
+
35
35
+
data['upcomingRsvps'] = { rsvps };
36
36
+
} catch (e) {
37
37
+
console.error('Failed to load RSVPs', e);
38
38
+
}
39
39
+
40
40
+
isLoaded = true;
41
41
+
});
42
42
+
43
43
+
function formatDate(dateStr: string): string {
44
44
+
const date = new Date(dateStr);
45
45
+
return date.toLocaleDateString('en-US', {
46
46
+
weekday: 'short',
47
47
+
month: 'short',
48
48
+
day: 'numeric'
49
49
+
});
50
50
+
}
51
51
+
52
52
+
function formatTime(dateStr: string): string {
53
53
+
const date = new Date(dateStr);
54
54
+
return date.toLocaleTimeString('en-US', {
55
55
+
hour: 'numeric',
56
56
+
minute: '2-digit'
57
57
+
});
58
58
+
}
59
59
+
60
60
+
function getModeLabel(mode: string): string {
61
61
+
if (mode.includes('virtual')) return 'Virtual';
62
62
+
if (mode.includes('hybrid')) return 'Hybrid';
63
63
+
if (mode.includes('inperson')) return 'In-Person';
64
64
+
return 'Event';
65
65
+
}
66
66
+
67
67
+
function getModeColor(mode: string): string {
68
68
+
if (mode.includes('virtual')) return 'blue';
69
69
+
if (mode.includes('hybrid')) return 'purple';
70
70
+
if (mode.includes('inperson')) return 'green';
71
71
+
return 'gray';
72
72
+
}
73
73
+
</script>
74
74
+
75
75
+
<div class="flex h-full flex-col overflow-hidden p-4">
76
76
+
<div class="mb-3 flex items-center gap-2">
77
77
+
<div
78
78
+
class="bg-base-100 border-base-300 accent:bg-accent-100/50 accent:border-accent-200 dark:border-base-800 dark:bg-base-900 flex size-8 shrink-0 items-center justify-center rounded-xl border"
79
79
+
>
80
80
+
<svg
81
81
+
xmlns="http://www.w3.org/2000/svg"
82
82
+
fill="none"
83
83
+
viewBox="0 0 24 24"
84
84
+
stroke-width="1.5"
85
85
+
stroke="currentColor"
86
86
+
class="size-4"
87
87
+
>
88
88
+
<path
89
89
+
stroke-linecap="round"
90
90
+
stroke-linejoin="round"
91
91
+
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
92
92
+
/>
93
93
+
</svg>
94
94
+
</div>
95
95
+
<span class="text-base-900 dark:text-base-50 text-sm font-semibold">RSVPs</span>
96
96
+
</div>
97
97
+
98
98
+
<div class="flex-1 overflow-y-auto">
99
99
+
{#if rsvps.length > 0}
100
100
+
<div class="flex flex-col gap-2">
101
101
+
{#each rsvps as rsvp (rsvp.eventUri)}
102
102
+
<a
103
103
+
href="https://blento.app/{rsvp.hostDid}/events/{rsvp.rkey}"
104
104
+
target="_blank"
105
105
+
class="hover:bg-base-100 dark:hover:bg-base-800 accent:hover:bg-accent-400/20 flex flex-col gap-1 rounded-lg p-2 transition-colors"
106
106
+
use:qrOverlay={{ context: { title: rsvp.event.name } }}
107
107
+
>
108
108
+
<div class="flex items-center gap-2">
109
109
+
<span class="text-base-900 dark:text-base-50 line-clamp-1 flex-1 text-sm font-medium"
110
110
+
>{rsvp.event.name}</span
111
111
+
>
112
112
+
<Badge size="sm" color={rsvp.status === 'going' ? 'green' : 'amber'}>
113
113
+
<span class="accent:text-base-900"
114
114
+
>{rsvp.status === 'going' ? 'Going' : 'Interested'}</span
115
115
+
>
116
116
+
</Badge>
117
117
+
</div>
118
118
+
<div
119
119
+
class="text-base-500 dark:text-base-400 accent:text-base-800 flex items-center gap-1 text-xs"
120
120
+
>
121
121
+
<svg
122
122
+
xmlns="http://www.w3.org/2000/svg"
123
123
+
fill="none"
124
124
+
viewBox="0 0 24 24"
125
125
+
stroke-width="1.5"
126
126
+
stroke="currentColor"
127
127
+
class="size-3 shrink-0"
128
128
+
>
129
129
+
<path
130
130
+
stroke-linecap="round"
131
131
+
stroke-linejoin="round"
132
132
+
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
133
133
+
/>
134
134
+
</svg>
135
135
+
<span>{formatDate(rsvp.event.startsAt)} at {formatTime(rsvp.event.startsAt)}</span>
136
136
+
</div>
137
137
+
{#if rsvp.hostProfile}
138
138
+
<div
139
139
+
class="text-base-500 dark:text-base-400 accent:text-base-800 flex items-center gap-1 text-xs"
140
140
+
>
141
141
+
<svg
142
142
+
xmlns="http://www.w3.org/2000/svg"
143
143
+
fill="none"
144
144
+
viewBox="0 0 24 24"
145
145
+
stroke-width="1.5"
146
146
+
stroke="currentColor"
147
147
+
class="size-3 shrink-0"
148
148
+
>
149
149
+
<path
150
150
+
stroke-linecap="round"
151
151
+
stroke-linejoin="round"
152
152
+
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
153
153
+
/>
154
154
+
</svg>
155
155
+
<span class="truncate"
156
156
+
>{rsvp.hostProfile.displayName || rsvp.hostProfile.handle}</span
157
157
+
>
158
158
+
</div>
159
159
+
{/if}
160
160
+
</a>
161
161
+
{/each}
162
162
+
</div>
163
163
+
{:else if isLoaded}
164
164
+
<div
165
165
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm"
166
166
+
>
167
167
+
No upcoming RSVPs
168
168
+
</div>
169
169
+
{:else}
170
170
+
<div
171
171
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm"
172
172
+
>
173
173
+
Loading RSVPs...
174
174
+
</div>
175
175
+
{/if}
176
176
+
</div>
177
177
+
</div>
+41
src/lib/cards/social/UpcomingRsvpsCard/index.ts
···
1
1
+
import { fetchUserRsvps } from '$lib/events/fetch-attendees';
2
2
+
import type { CardDefinition } from '../../types';
3
3
+
import UpcomingRsvpsCard from './UpcomingRsvpsCard.svelte';
4
4
+
import type { ResolvedRsvp } from '$lib/events/fetch-attendees';
5
5
+
6
6
+
export type { ResolvedRsvp };
7
7
+
8
8
+
export const UpcomingRsvpsCardDefinition = {
9
9
+
type: 'upcomingRsvps',
10
10
+
contentComponent: UpcomingRsvpsCard,
11
11
+
createNew: (card) => {
12
12
+
card.w = 4;
13
13
+
card.h = 4;
14
14
+
card.mobileW = 8;
15
15
+
card.mobileH = 6;
16
16
+
},
17
17
+
minW: 2,
18
18
+
minH: 3,
19
19
+
20
20
+
loadData: async (_items, { did, cache }) => {
21
21
+
const rsvps = await fetchUserRsvps(did, cache);
22
22
+
23
23
+
const now = new Date();
24
24
+
const upcoming = rsvps.filter((r) => {
25
25
+
const endsAt = r.event.endsAt ? new Date(r.event.endsAt) : null;
26
26
+
const startsAt = new Date(r.event.startsAt);
27
27
+
return (endsAt && endsAt >= now) || (!endsAt && startsAt >= now);
28
28
+
});
29
29
+
30
30
+
upcoming.sort(
31
31
+
(a, b) => new Date(a.event.startsAt).getTime() - new Date(b.event.startsAt).getTime()
32
32
+
);
33
33
+
34
34
+
return { rsvps: upcoming };
35
35
+
},
36
36
+
37
37
+
name: 'Upcoming RSVPs',
38
38
+
keywords: ['rsvp', 'attending', 'going', 'interested', 'events'],
39
39
+
groups: ['Social'],
40
40
+
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>`
41
41
+
} as CardDefinition & { type: 'upcomingRsvps' };
+1
-1
src/lib/website/Account.svelte
···
20
20
<Popover sideOffset={8} bind:open={settingsPopoverOpen} class="bg-base-100 dark:bg-base-900">
21
21
{#snippet child({ props })}
22
22
<button {...props}>
23
23
-
<Avatar src={user.profile?.avatar} alt="" class="size-15 rounded-full" />
23
23
+
<Avatar src={user.profile?.avatar} alt="" class="size-15 rounded-full cursor-pointer" />
24
24
</button>
25
25
{/snippet}
26
26