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