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