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 } 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 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 && parsedUri?.repo) {
31 const loadedData = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], {
32 did: parsedUri.repo as Did,
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 (parsedUri) {
94 return `https://blento.app/${parsedUri.repo}/events/${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.repo}/${header.content.ref.$link}@jpeg`,
107 alt: header.alt || eventData.name
108 };
109 });
110
111 let showImage = $derived(browser && headerImage());
112</script>
113
114<div class="event-card flex h-full flex-col justify-between overflow-hidden p-4">
115 {#if eventData}
116 <div class="min-w-0 flex-1 overflow-hidden">
117 <div class="mb-2 flex items-center justify-between gap-2">
118 <div class="flex items-center gap-2">
119 <div
120 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"
121 >
122 <svg
123 xmlns="http://www.w3.org/2000/svg"
124 fill="none"
125 viewBox="0 0 24 24"
126 stroke-width="1.5"
127 stroke="currentColor"
128 class="size-4"
129 >
130 <path
131 stroke-linecap="round"
132 stroke-linejoin="round"
133 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"
134 />
135 </svg>
136 </div>
137 <Badge size="sm" color={getModeColor(eventData.mode)}>
138 <span class="accent:text-base-900">{getModeLabel(eventData.mode)}</span>
139 </Badge>
140 </div>
141
142 <div class="event-action z-50">
143 <Button href={eventUrl()} target="_blank">View event</Button>
144 </div>
145 </div>
146
147 <h3 class="text-base-900 dark:text-base-50 mb-2 line-clamp-2 text-lg leading-tight font-bold">
148 {eventData.name}
149 </h3>
150
151 <div class="text-base-600 dark:text-base-400 accent:text-base-800 mb-2 text-sm">
152 <div class="flex items-center gap-1">
153 <svg
154 xmlns="http://www.w3.org/2000/svg"
155 fill="none"
156 viewBox="0 0 24 24"
157 stroke-width="1.5"
158 stroke="currentColor"
159 class="size-4 shrink-0"
160 >
161 <path
162 stroke-linecap="round"
163 stroke-linejoin="round"
164 d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
165 />
166 </svg>
167 <span class="truncate">
168 {formatDate(eventData.startsAt)} at {formatTime(eventData.startsAt)}
169 {#if eventData.endsAt}
170 - {formatDate(eventData.endsAt)}
171 {/if}
172 </span>
173 </div>
174 </div>
175
176 {#if location}
177 <div
178 class="text-base-600 dark:text-base-400 accent:text-base-800 mb-2 flex items-center gap-1 text-sm"
179 >
180 <svg
181 xmlns="http://www.w3.org/2000/svg"
182 fill="none"
183 viewBox="0 0 24 24"
184 stroke-width="1.5"
185 stroke="currentColor"
186 class="size-4 shrink-0"
187 >
188 <path
189 stroke-linecap="round"
190 stroke-linejoin="round"
191 d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
192 />
193 <path
194 stroke-linecap="round"
195 stroke-linejoin="round"
196 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"
197 />
198 </svg>
199 <span class="truncate">{location}</span>
200 </div>
201 {/if}
202
203 {#if eventData.description}
204 <p
205 class="event-description text-base-500 dark:text-base-400 accent:text-base-900 mb-3 line-clamp-3 text-sm"
206 >
207 {eventData.description}
208 </p>
209 {/if}
210 </div>
211
212 {#if showImage}
213 {@const img = headerImage()}
214 {#if img}
215 <img
216 src={img.url}
217 alt={img.alt}
218 class="event-image mt-3 aspect-3/1 w-full rounded-xl object-cover"
219 />
220 {/if}
221 {/if}
222
223 <a
224 href={eventUrl()}
225 target="_blank"
226 class="absolute inset-0 h-full w-full"
227 use:qrOverlay={{
228 context: {
229 title: eventData?.name ?? ''
230 }
231 }}
232 >
233 <span class="sr-only">View event</span>
234 </a>
235 {:else if isLoaded}
236 <div class="flex h-full w-full items-center justify-center">
237 <span class="text-base-500 dark:text-base-400">Event not found</span>
238 </div>
239 {:else}
240 <div class="flex h-full w-full items-center justify-center">
241 <span class="text-base-500 dark:text-base-400">Loading event...</span>
242 </div>
243 {/if}
244</div>
245
246<style>
247 .event-action,
248 .event-description,
249 .event-image {
250 display: none;
251 }
252
253 @container card (width >= 18rem) {
254 .event-action {
255 display: inline-flex;
256 }
257 }
258
259 @container card (height >= 12rem) {
260 .event-description {
261 display: block;
262 }
263 }
264
265 @container card (height >= 15rem) {
266 .event-image {
267 display: block;
268 }
269 }
270</style>