your personal website on atproto - mirror
blento.app
1<script lang="ts">
2 import { onMount } from 'svelte';
3 import { Badge, Button } from '@foxui/core';
4 import { getAdditionalUserData, getIsMobile } from '$lib/website/context';
5 import type { ContentComponentProps } from '../types';
6 import { CardDefinitionsByType } from '..';
7 import type { EventData } from '.';
8 import { parseUri } from '$lib/atproto';
9 import { browser } from '$app/environment';
10 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte';
11 import type { Did } from '@atcute/lexicons';
12
13 let { item }: ContentComponentProps = $props();
14
15 let isMobile = getIsMobile();
16 let isLoaded = $state(false);
17 let fetchedEventData = $state<EventData | undefined>(undefined);
18
19 const data = getAdditionalUserData();
20
21 let eventData = $derived(
22 fetchedEventData ||
23 ((data[item.cardType] as Record<string, EventData> | undefined)?.[item.id] as
24 | EventData
25 | undefined)
26 );
27
28 let parsedUri = $derived(item.cardData?.uri ? parseUri(item.cardData.uri) : null);
29
30 onMount(async () => {
31 if (!eventData && item.cardData?.uri && parsedUri?.repo) {
32 const loadedData = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], {
33 did: parsedUri.repo as Did,
34 handle: ''
35 })) as Record<string, EventData> | undefined;
36
37 if (loadedData?.[item.id]) {
38 fetchedEventData = loadedData[item.id];
39 if (!data[item.cardType]) {
40 data[item.cardType] = {};
41 }
42 (data[item.cardType] as Record<string, EventData>)[item.id] = fetchedEventData;
43 }
44 }
45 isLoaded = true;
46 });
47
48 function formatDate(dateStr: string): string {
49 const date = new Date(dateStr);
50 return date.toLocaleDateString('en-US', {
51 weekday: 'short',
52 month: 'short',
53 day: 'numeric',
54 year: 'numeric'
55 });
56 }
57
58 function formatTime(dateStr: string): string {
59 const date = new Date(dateStr);
60 return date.toLocaleTimeString('en-US', {
61 hour: 'numeric',
62 minute: '2-digit'
63 });
64 }
65
66 function getModeLabel(mode: string): string {
67 if (mode.includes('virtual')) return 'Virtual';
68 if (mode.includes('hybrid')) return 'Hybrid';
69 if (mode.includes('inperson')) return 'In-Person';
70 return 'Event';
71 }
72
73 function getModeColor(mode: string): string {
74 if (mode.includes('virtual')) return 'blue';
75 if (mode.includes('hybrid')) return 'purple';
76 if (mode.includes('inperson')) return 'green';
77 return 'gray';
78 }
79
80 function getLocationString(
81 locations:
82 | Array<{ address?: { locality?: string; region?: string; country?: string } }>
83 | undefined
84 ): string | undefined {
85 if (!locations || locations.length === 0) return undefined;
86 const loc = locations[0]?.address;
87 if (!loc) return undefined;
88
89 const parts = [loc.locality, loc.region, loc.country].filter(Boolean);
90 return parts.length > 0 ? parts.join(', ') : undefined;
91 }
92
93 let eventUrl = $derived(() => {
94 if (eventData?.url) return eventData.url;
95 if (parsedUri) {
96 return `https://smokesignal.events/${parsedUri.repo}/${parsedUri.rkey}`;
97 }
98 return '#';
99 });
100
101 let location = $derived(getLocationString(eventData?.locations));
102
103 let headerImage = $derived(() => {
104 if (!eventData?.media || !parsedUri) return null;
105 const header = eventData.media.find((m) => m.role === 'header');
106 if (!header?.content?.ref?.$link) return null;
107 return {
108 url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.repo}/${header.content.ref.$link}@jpeg`,
109 alt: header.alt || eventData.name
110 };
111 });
112
113 let showImage = $derived(
114 browser && headerImage() && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4))
115 );
116</script>
117
118<div class="flex h-full flex-col justify-between overflow-hidden p-4">
119 {#if eventData}
120 <div class="min-w-0 flex-1 overflow-hidden">
121 <div class="mb-2 flex items-center justify-between gap-2">
122 <div class="flex items-center gap-2">
123 <div
124 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"
125 >
126 <svg
127 xmlns="http://www.w3.org/2000/svg"
128 fill="none"
129 viewBox="0 0 24 24"
130 stroke-width="1.5"
131 stroke="currentColor"
132 class="size-4"
133 >
134 <path
135 stroke-linecap="round"
136 stroke-linejoin="round"
137 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"
138 />
139 </svg>
140 </div>
141 <Badge size="sm" color={getModeColor(eventData.mode)}>
142 <span class="accent:text-base-900">{getModeLabel(eventData.mode)}</span>
143 </Badge>
144 </div>
145
146 {#if isMobile() ? item.mobileW > 4 : item.w > 2}
147 <Button href={eventUrl()} target="_blank" rel="noopener noreferrer" class="z-50"
148 >View event</Button
149 >
150 {/if}
151 </div>
152
153 <h3 class="text-base-900 dark:text-base-50 mb-2 line-clamp-2 text-lg leading-tight font-bold">
154 {eventData.name}
155 </h3>
156
157 <div class="text-base-600 dark:text-base-400 accent:text-base-800 mb-2 text-sm">
158 <div class="flex items-center gap-1">
159 <svg
160 xmlns="http://www.w3.org/2000/svg"
161 fill="none"
162 viewBox="0 0 24 24"
163 stroke-width="1.5"
164 stroke="currentColor"
165 class="size-4 shrink-0"
166 >
167 <path
168 stroke-linecap="round"
169 stroke-linejoin="round"
170 d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
171 />
172 </svg>
173 <span class="truncate">
174 {formatDate(eventData.startsAt)} at {formatTime(eventData.startsAt)}
175 {#if eventData.endsAt}
176 - {formatDate(eventData.endsAt)}
177 {/if}
178 </span>
179 </div>
180 </div>
181
182 {#if location}
183 <div
184 class="text-base-600 dark:text-base-400 accent:text-base-800 mb-2 flex items-center gap-1 text-sm"
185 >
186 <svg
187 xmlns="http://www.w3.org/2000/svg"
188 fill="none"
189 viewBox="0 0 24 24"
190 stroke-width="1.5"
191 stroke="currentColor"
192 class="size-4 shrink-0"
193 >
194 <path
195 stroke-linecap="round"
196 stroke-linejoin="round"
197 d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
198 />
199 <path
200 stroke-linecap="round"
201 stroke-linejoin="round"
202 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"
203 />
204 </svg>
205 <span class="truncate">{location}</span>
206 </div>
207 {/if}
208
209 {#if eventData.description && ((isMobile() && item.mobileH >= 5) || (!isMobile() && item.h >= 3))}
210 <p class="text-base-500 dark:text-base-400 accent:text-base-900 mb-3 line-clamp-3 text-sm">
211 {eventData.description}
212 </p>
213 {/if}
214
215 {#if (eventData.countGoing !== undefined || eventData.countInterested !== undefined) && ((isMobile() && item.mobileH >= 4) || (!isMobile() && item.h >= 3))}
216 <div
217 class="text-base-600 dark:text-base-400 accent:text-base-800 flex flex-wrap gap-3 text-xs"
218 >
219 {#if eventData.countGoing !== undefined}
220 <div class="flex items-center gap-1">
221 <svg
222 xmlns="http://www.w3.org/2000/svg"
223 fill="none"
224 viewBox="0 0 24 24"
225 stroke-width="1.5"
226 stroke="currentColor"
227 class="size-4"
228 >
229 <path
230 stroke-linecap="round"
231 stroke-linejoin="round"
232 d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
233 />
234 </svg>
235 <span>{eventData.countGoing} going</span>
236 </div>
237 {/if}
238 {#if eventData.countInterested !== undefined}
239 <div class="flex items-center gap-1">
240 <svg
241 xmlns="http://www.w3.org/2000/svg"
242 fill="none"
243 viewBox="0 0 24 24"
244 stroke-width="1.5"
245 stroke="currentColor"
246 class="size-4"
247 >
248 <path
249 stroke-linecap="round"
250 stroke-linejoin="round"
251 d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z"
252 />
253 </svg>
254 <span>{eventData.countInterested} interested</span>
255 </div>
256 {/if}
257 </div>
258 {/if}
259 </div>
260
261 {#if showImage}
262 {@const img = headerImage()}
263 {#if img}
264 <img src={img.url} alt={img.alt} class="mt-3 aspect-3/1 w-full rounded-xl object-cover" />
265 {/if}
266 {/if}
267
268 <a
269 href={eventUrl()}
270 class="absolute inset-0 h-full w-full"
271 target="_blank"
272 rel="noopener noreferrer"
273 use:qrOverlay={{
274 context: {
275 title: eventData?.name ?? ''
276 }
277 }}
278 >
279 <span class="sr-only">View event on smokesignal.events</span>
280 </a>
281 {:else if isLoaded}
282 <div class="flex h-full w-full items-center justify-center">
283 <span class="text-base-500 dark:text-base-400">Event not found</span>
284 </div>
285 {:else}
286 <div class="flex h-full w-full items-center justify-center">
287 <span class="text-base-500 dark:text-base-400">Loading event...</span>
288 </div>
289 {/if}
290</div>