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 import { page } from '$app/state';
13
14 let { item }: ContentComponentProps = $props();
15
16 let isMobile = getIsMobile();
17 let isLoaded = $state(false);
18 let fetchedEventData = $state<EventData | undefined>(undefined);
19
20 const data = getAdditionalUserData();
21
22 let eventData = $derived(
23 fetchedEventData ||
24 ((data[item.cardType] as Record<string, EventData> | undefined)?.[item.id] as
25 | EventData
26 | undefined)
27 );
28
29 let parsedUri = $derived(item.cardData?.uri ? parseUri(item.cardData.uri) : null);
30
31 onMount(async () => {
32 if (!eventData && item.cardData?.uri && parsedUri?.repo) {
33 const loadedData = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], {
34 did: parsedUri.repo as Did,
35 handle: ''
36 })) as Record<string, EventData> | undefined;
37
38 if (loadedData?.[item.id]) {
39 fetchedEventData = loadedData[item.id];
40 if (!data[item.cardType]) {
41 data[item.cardType] = {};
42 }
43 (data[item.cardType] as Record<string, EventData>)[item.id] = fetchedEventData;
44 }
45 }
46 isLoaded = true;
47 });
48
49 function formatDate(dateStr: string): string {
50 const date = new Date(dateStr);
51 return date.toLocaleDateString('en-US', {
52 weekday: 'short',
53 month: 'short',
54 day: 'numeric',
55 year: 'numeric'
56 });
57 }
58
59 function formatTime(dateStr: string): string {
60 const date = new Date(dateStr);
61 return date.toLocaleTimeString('en-US', {
62 hour: 'numeric',
63 minute: '2-digit'
64 });
65 }
66
67 function getModeLabel(mode: string): string {
68 if (mode.includes('virtual')) return 'Virtual';
69 if (mode.includes('hybrid')) return 'Hybrid';
70 if (mode.includes('inperson')) return 'In-Person';
71 return 'Event';
72 }
73
74 function getModeColor(mode: string): string {
75 if (mode.includes('virtual')) return 'blue';
76 if (mode.includes('hybrid')) return 'purple';
77 if (mode.includes('inperson')) return 'green';
78 return 'gray';
79 }
80
81 function getLocationString(
82 locations:
83 | Array<{ address?: { locality?: string; region?: string; country?: string } }>
84 | undefined
85 ): string | undefined {
86 if (!locations || locations.length === 0) return undefined;
87 const loc = locations[0]?.address;
88 if (!loc) return undefined;
89
90 const parts = [loc.locality, loc.region, loc.country].filter(Boolean);
91 return parts.length > 0 ? parts.join(', ') : undefined;
92 }
93
94 let eventUrl = $derived(() => {
95 if (parsedUri) {
96 const actorPrefix = page.params.actor ? `/${page.params.actor}` : '';
97 return `${actorPrefix}/events/${parsedUri.rkey}`;
98 }
99 return '#';
100 });
101
102 let location = $derived(getLocationString(eventData?.locations));
103
104 let headerImage = $derived(() => {
105 if (!eventData?.media || !parsedUri) return null;
106 const header = eventData.media.find((m) => m.role === 'header');
107 if (!header?.content?.ref?.$link) return null;
108 return {
109 url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.repo}/${header.content.ref.$link}@jpeg`,
110 alt: header.alt || eventData.name
111 };
112 });
113
114 let showImage = $derived(
115 browser && headerImage() && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4))
116 );
117</script>
118
119<div class="flex h-full flex-col justify-between overflow-hidden p-4">
120 {#if eventData}
121 <div class="min-w-0 flex-1 overflow-hidden">
122 <div class="mb-2 flex items-center justify-between gap-2">
123 <div class="flex items-center gap-2">
124 <div
125 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"
126 >
127 <svg
128 xmlns="http://www.w3.org/2000/svg"
129 fill="none"
130 viewBox="0 0 24 24"
131 stroke-width="1.5"
132 stroke="currentColor"
133 class="size-4"
134 >
135 <path
136 stroke-linecap="round"
137 stroke-linejoin="round"
138 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"
139 />
140 </svg>
141 </div>
142 <Badge size="sm" color={getModeColor(eventData.mode)}>
143 <span class="accent:text-base-900">{getModeLabel(eventData.mode)}</span>
144 </Badge>
145 </div>
146
147 {#if isMobile() ? item.mobileW > 4 : item.w > 2}
148 <Button href={eventUrl()} target="_blank" class="z-50">View event</Button>
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 </div>
214
215 {#if showImage}
216 {@const img = headerImage()}
217 {#if img}
218 <img src={img.url} alt={img.alt} class="mt-3 aspect-3/1 w-full rounded-xl object-cover" />
219 {/if}
220 {/if}
221
222 <a
223 href={eventUrl()}
224 target="_blank"
225 class="absolute inset-0 h-full w-full"
226 use:qrOverlay={{
227 context: {
228 title: eventData?.name ?? ''
229 }
230 }}
231 >
232 <span class="sr-only">View event</span>
233 </a>
234 {:else if isLoaded}
235 <div class="flex h-full w-full items-center justify-center">
236 <span class="text-base-500 dark:text-base-400">Event not found</span>
237 </div>
238 {:else}
239 <div class="flex h-full w-full items-center justify-center">
240 <span class="text-base-500 dark:text-base-400">Loading event...</span>
241 </div>
242 {/if}
243</div>