your personal website on atproto - mirror blento.app
at custom-domains 290 lines 9.1 kB view raw
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>