your personal website on atproto - mirror blento.app
at update-link-card 241 lines 7.3 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 (parsedUri) { 95 return `https://blento.app/${parsedUri.repo}/events/${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.repo}/${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" class="z-50">View event</Button> 147 {/if} 148 </div> 149 150 <h3 class="text-base-900 dark:text-base-50 mb-2 line-clamp-2 text-lg leading-tight font-bold"> 151 {eventData.name} 152 </h3> 153 154 <div class="text-base-600 dark:text-base-400 accent:text-base-800 mb-2 text-sm"> 155 <div class="flex items-center gap-1"> 156 <svg 157 xmlns="http://www.w3.org/2000/svg" 158 fill="none" 159 viewBox="0 0 24 24" 160 stroke-width="1.5" 161 stroke="currentColor" 162 class="size-4 shrink-0" 163 > 164 <path 165 stroke-linecap="round" 166 stroke-linejoin="round" 167 d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 168 /> 169 </svg> 170 <span class="truncate"> 171 {formatDate(eventData.startsAt)} at {formatTime(eventData.startsAt)} 172 {#if eventData.endsAt} 173 - {formatDate(eventData.endsAt)} 174 {/if} 175 </span> 176 </div> 177 </div> 178 179 {#if location} 180 <div 181 class="text-base-600 dark:text-base-400 accent:text-base-800 mb-2 flex items-center gap-1 text-sm" 182 > 183 <svg 184 xmlns="http://www.w3.org/2000/svg" 185 fill="none" 186 viewBox="0 0 24 24" 187 stroke-width="1.5" 188 stroke="currentColor" 189 class="size-4 shrink-0" 190 > 191 <path 192 stroke-linecap="round" 193 stroke-linejoin="round" 194 d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 195 /> 196 <path 197 stroke-linecap="round" 198 stroke-linejoin="round" 199 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" 200 /> 201 </svg> 202 <span class="truncate">{location}</span> 203 </div> 204 {/if} 205 206 {#if eventData.description && ((isMobile() && item.mobileH >= 5) || (!isMobile() && item.h >= 3))} 207 <p class="text-base-500 dark:text-base-400 accent:text-base-900 mb-3 line-clamp-3 text-sm"> 208 {eventData.description} 209 </p> 210 {/if} 211 </div> 212 213 {#if showImage} 214 {@const img = headerImage()} 215 {#if img} 216 <img src={img.url} alt={img.alt} class="mt-3 aspect-3/1 w-full rounded-xl object-cover" /> 217 {/if} 218 {/if} 219 220 <a 221 href={eventUrl()} 222 target="_blank" 223 class="absolute inset-0 h-full w-full" 224 use:qrOverlay={{ 225 context: { 226 title: eventData?.name ?? '' 227 } 228 }} 229 > 230 <span class="sr-only">View event</span> 231 </a> 232 {:else if isLoaded} 233 <div class="flex h-full w-full items-center justify-center"> 234 <span class="text-base-500 dark:text-base-400">Event not found</span> 235 </div> 236 {:else} 237 <div class="flex h-full w-full items-center justify-center"> 238 <span class="text-base-500 dark:text-base-400">Loading event...</span> 239 </div> 240 {/if} 241</div>