your personal website on atproto - mirror blento.app
at qr-codes 289 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 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>