your personal website on atproto - mirror blento.app

add og images

Florian fb4d93d0 dcb98beb

+416 -9
+1 -1
docs/Autofill.md
··· 11 11 - blue.flashes.actor.portfolio 12 12 - social.grain.gallery 13 13 - xyz.statusphere.status 14 - - site.standard.publication 14 + - site.standard.publication 15 15 - fm.teal.alpha.feed.play 16 16 - dev.npmx.feed.like 17 17 - add bluesky profile card
+4 -2
src/lib/atproto/methods.ts
··· 395 395 */ 396 396 export function getCDNImageBlobUrl({ 397 397 did, 398 - blob 398 + blob, 399 + type = 'webp' 399 400 }: { 400 401 did?: string; 401 402 blob: { ··· 404 405 $link: string; 405 406 }; 406 407 }; 408 + type?: 'webp' | 'jpeg'; 407 409 }) { 408 410 if (!blob || !did) return; 409 411 did ??= user.did; 410 412 411 - return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; 413 + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@${type}`; 412 414 } 413 415 414 416 /**
+2 -1
src/lib/cards/social/EventCard/index.ts
··· 24 24 alt?: string; 25 25 role?: string; 26 26 content?: { 27 - ref?: { 27 + $type: 'blob'; 28 + ref: { 28 29 $link: string; 29 30 }; 30 31 mimeType?: string;
+77
src/routes/[[actor=actor]]/e/+page.server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import type { EventData } from '$lib/cards/social/EventCard'; 3 + import { getBlentoOrBskyProfile, resolveHandle } from '$lib/atproto/methods.js'; 4 + import { isHandle } from '@atcute/lexicons/syntax'; 5 + import { createCache, type CachedProfile } from '$lib/cache'; 6 + import type { ActorIdentifier, Did } from '@atcute/lexicons'; 7 + import { env as publicEnv } from '$env/dynamic/public'; 8 + 9 + export async function load({ params, platform, request }) { 10 + const cache = createCache(platform); 11 + 12 + const customDomain = request.headers.get('X-Custom-Domain')?.toLowerCase(); 13 + 14 + let actor: ActorIdentifier | undefined = params.actor; 15 + 16 + if (!actor) { 17 + const kv = platform?.env?.CUSTOM_DOMAINS; 18 + 19 + if (kv && customDomain) { 20 + try { 21 + const did = await kv.get(customDomain); 22 + 23 + if (did) actor = did as ActorIdentifier; 24 + } catch (error) { 25 + console.error('failed to get custom domain kv', error); 26 + } 27 + } else { 28 + actor = publicEnv.PUBLIC_HANDLE as ActorIdentifier; 29 + } 30 + } else if (customDomain && params.actor) { 31 + actor = undefined; 32 + } 33 + 34 + const did = isHandle(actor) ? await resolveHandle({ handle: actor }) : actor; 35 + 36 + if (!did) { 37 + throw error(404, 'Events not found'); 38 + } 39 + 40 + try { 41 + const [eventsResponse, hostProfile] = await Promise.all([ 42 + fetch( 43 + `https://smokesignal.events/xrpc/community.lexicon.calendar.searchEvents?repository=${encodeURIComponent(did)}&query=upcoming` 44 + ), 45 + cache 46 + ? cache.getProfile(did as Did).catch(() => null) 47 + : getBlentoOrBskyProfile({ did: did as Did }) 48 + .then( 49 + (p): CachedProfile => ({ 50 + did: p.did as string, 51 + handle: p.handle as string, 52 + displayName: p.displayName as string | undefined, 53 + avatar: p.avatar as string | undefined, 54 + hasBlento: p.hasBlento, 55 + url: p.url 56 + }) 57 + ) 58 + .catch(() => null) 59 + ]); 60 + 61 + if (!eventsResponse.ok) { 62 + throw error(404, 'Events not found'); 63 + } 64 + 65 + const data: { results: EventData[] } = await eventsResponse.json(); 66 + const events = data.results; 67 + 68 + return { 69 + events, 70 + did, 71 + hostProfile: hostProfile ?? null 72 + }; 73 + } catch (e) { 74 + if (e && typeof e === 'object' && 'status' in e) throw e; 75 + throw error(404, 'Events not found'); 76 + } 77 + }
+170
src/routes/[[actor=actor]]/e/+page.svelte
··· 1 + <script lang="ts"> 2 + import type { EventData } from '$lib/cards/social/EventCard'; 3 + import { getCDNImageBlobUrl } from '$lib/atproto'; 4 + import { Avatar as FoxAvatar, Badge } from '@foxui/core'; 5 + import Avatar from 'svelte-boring-avatars'; 6 + 7 + let { data } = $props(); 8 + 9 + let events: EventData[] = $derived(data.events); 10 + let did: string = $derived(data.did); 11 + let hostProfile = $derived(data.hostProfile); 12 + 13 + let hostName = $derived(hostProfile?.displayName || hostProfile?.handle || did); 14 + 15 + function formatDate(dateStr: string): string { 16 + const date = new Date(dateStr); 17 + const options: Intl.DateTimeFormatOptions = { 18 + weekday: 'short', 19 + month: 'short', 20 + day: 'numeric' 21 + }; 22 + if (date.getFullYear() !== new Date().getFullYear()) { 23 + options.year = 'numeric'; 24 + } 25 + return date.toLocaleDateString('en-US', options); 26 + } 27 + 28 + function formatTime(dateStr: string): string { 29 + return new Date(dateStr).toLocaleTimeString('en-US', { 30 + hour: 'numeric', 31 + minute: '2-digit' 32 + }); 33 + } 34 + 35 + function getModeLabel(mode: string): string { 36 + if (mode.includes('virtual')) return 'Virtual'; 37 + if (mode.includes('hybrid')) return 'Hybrid'; 38 + if (mode.includes('inperson')) return 'In-Person'; 39 + return 'Event'; 40 + } 41 + 42 + function getModeColor(mode: string): 'cyan' | 'purple' | 'amber' | 'secondary' { 43 + if (mode.includes('virtual')) return 'cyan'; 44 + if (mode.includes('hybrid')) return 'purple'; 45 + if (mode.includes('inperson')) return 'amber'; 46 + return 'secondary'; 47 + } 48 + 49 + function getLocationString(locations: EventData['locations']): string | undefined { 50 + if (!locations || locations.length === 0) return undefined; 51 + 52 + const loc = locations.find((v) => v.$type === 'community.lexicon.location.address'); 53 + if (!loc) return undefined; 54 + 55 + const flat = loc as Record<string, unknown>; 56 + const nested = loc.address; 57 + 58 + const locality = (flat.locality as string) || nested?.locality; 59 + const region = (flat.region as string) || nested?.region; 60 + 61 + const parts = [locality, region].filter(Boolean); 62 + return parts.length > 0 ? parts.join(', ') : undefined; 63 + } 64 + 65 + function getThumbnail(event: EventData): { url: string; alt: string } | null { 66 + if (!event.media || event.media.length === 0) return null; 67 + const media = event.media.find((m) => m.role === 'thumbnail'); 68 + if (!media?.content) return null; 69 + const url = getCDNImageBlobUrl({ did, blob: media.content, type: 'jpeg' }); 70 + if (!url) return null; 71 + return { url, alt: media.alt || event.name }; 72 + } 73 + 74 + function getRkey(event: EventData): string { 75 + return event.url.split('/').pop() || ''; 76 + } 77 + 78 + let actorPrefix = $derived(data.hostProfile?.handle ? `/${data.hostProfile.handle}` : `/${did}`); 79 + </script> 80 + 81 + <svelte:head> 82 + <title>{hostName} - Events</title> 83 + <meta name="description" content="Events hosted by {hostName}" /> 84 + <meta property="og:title" content="{hostName} - Events" /> 85 + <meta property="og:description" content="Events hosted by {hostName}" /> 86 + <meta name="twitter:card" content="summary" /> 87 + <meta name="twitter:title" content="{hostName} - Events" /> 88 + <meta name="twitter:description" content="Events hosted by {hostName}" /> 89 + </svelte:head> 90 + 91 + <div class="bg-base-50 dark:bg-base-950 min-h-screen px-8 py-8 sm:py-12"> 92 + <div class="mx-auto max-w-4xl"> 93 + <!-- Header --> 94 + <div class="mb-8 flex items-center gap-3"> 95 + <FoxAvatar src={hostProfile?.avatar} alt={hostName} class="size-10 shrink-0" /> 96 + <div> 97 + <h1 class="text-base-900 dark:text-base-50 text-2xl font-bold sm:text-3xl">Events</h1> 98 + <p class="text-base-500 dark:text-base-400 text-sm">Hosted by {hostName}</p> 99 + </div> 100 + </div> 101 + 102 + {#if events.length === 0} 103 + <p class="text-base-500 dark:text-base-400 py-12 text-center">No events found.</p> 104 + {:else} 105 + <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> 106 + {#each events as event (event.url)} 107 + {@const thumbnail = getThumbnail(event)} 108 + {@const location = getLocationString(event.locations)} 109 + {@const rkey = getRkey(event)} 110 + <a 111 + href="{actorPrefix}/e/{rkey}" 112 + class="border-base-200 dark:border-base-800 hover:border-base-300 dark:hover:border-base-700 group block overflow-hidden rounded-xl border transition-colors" 113 + > 114 + <!-- Thumbnail --> 115 + {#if thumbnail} 116 + <img 117 + src={thumbnail.url} 118 + alt={thumbnail.alt} 119 + class="aspect-[3/2] w-full object-cover" 120 + /> 121 + {:else} 122 + <div 123 + class="bg-base-100 dark:bg-base-900 aspect-[3/2] w-full [&>svg]:h-full [&>svg]:w-full" 124 + > 125 + <Avatar 126 + size={400} 127 + name={rkey} 128 + variant="marble" 129 + colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 130 + square 131 + /> 132 + </div> 133 + {/if} 134 + 135 + <!-- Content --> 136 + <div class="p-4"> 137 + <h2 138 + class="text-base-900 dark:text-base-50 group-hover:text-base-700 dark:group-hover:text-base-200 mb-1 leading-snug font-semibold" 139 + > 140 + {event.name} 141 + </h2> 142 + 143 + <p class="text-base-500 dark:text-base-400 mb-2 text-sm"> 144 + {formatDate(event.startsAt)} &middot; {formatTime(event.startsAt)} 145 + </p> 146 + 147 + <div class="flex flex-wrap items-center gap-2"> 148 + {#if event.mode} 149 + <Badge size="sm" variant={getModeColor(event.mode)} 150 + >{getModeLabel(event.mode)}</Badge 151 + > 152 + {/if} 153 + 154 + {#if location} 155 + <span class="text-base-500 dark:text-base-400 truncate text-xs">{location}</span> 156 + {/if} 157 + </div> 158 + 159 + {#if event.countGoing && event.countGoing > 0} 160 + <p class="text-base-500 dark:text-base-400 mt-2 text-xs"> 161 + {event.countGoing} going 162 + </p> 163 + {/if} 164 + </div> 165 + </a> 166 + {/each} 167 + </div> 168 + {/if} 169 + </div> 170 + </div>
+15 -5
src/routes/[[actor=actor]]/e/[rkey]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import type { EventData } from '$lib/cards/social/EventCard'; 3 + import { getCDNImageBlobUrl } from '$lib/atproto'; 3 4 import { Avatar as FoxAvatar, Badge } from '@foxui/core'; 4 5 import Avatar from 'svelte-boring-avatars'; 5 6 import EventRsvp from './EventRsvp.svelte'; 7 + import { page } from '$app/state'; 6 8 7 9 let { data } = $props(); 8 10 ··· 80 82 let headerImage = $derived.by(() => { 81 83 if (!eventData.media || eventData.media.length === 0) return null; 82 84 const media = eventData.media.find((m) => m.role === 'thumbnail'); 83 - if (!media?.content?.ref?.$link) return null; 84 - return { 85 - url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${media.content.ref.$link}@jpeg`, 86 - alt: media.alt || eventData.name 87 - }; 85 + if (!media?.content) return null; 86 + const url = getCDNImageBlobUrl({ did, blob: media.content, type: 'jpeg' }); 87 + if (!url) return null; 88 + return { url, alt: media.alt || eventData.name }; 88 89 }); 89 90 90 91 let eventUrl = $derived(eventData.url || `https://smokesignal.events/${did}/${rkey}`); 91 92 let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`); 93 + 94 + let ogImageUrl = $derived(`${page.url.origin}${page.url.pathname}/og.png`); 92 95 </script> 93 96 94 97 <svelte:head> 95 98 <title>{eventData.name}</title> 96 99 <meta name="description" content={eventData.description || `Event: ${eventData.name}`} /> 100 + <meta property="og:title" content={eventData.name} /> 101 + <meta property="og:description" content={eventData.description || `Event: ${eventData.name}`} /> 102 + <meta property="og:image" content={ogImageUrl} /> 103 + <meta name="twitter:card" content="summary_large_image" /> 104 + <meta name="twitter:title" content={eventData.name} /> 105 + <meta name="twitter:description" content={eventData.description || `Event: ${eventData.name}`} /> 106 + <meta name="twitter:image" content={ogImageUrl} /> 97 107 </svelte:head> 98 108 99 109 <div class="bg-base-50 dark:bg-base-950 min-h-screen px-8 py-8 sm:py-12">
+84
src/routes/[[actor=actor]]/e/[rkey]/og.png/+server.ts
··· 1 + import { getCDNImageBlobUrl, resolveHandle } from '$lib/atproto/methods.js'; 2 + import { env as publicEnv } from '$env/dynamic/public'; 3 + 4 + import type { ActorIdentifier } from '@atcute/lexicons'; 5 + import { isHandle } from '@atcute/lexicons/syntax'; 6 + import type { EventData } from '$lib/cards/social/EventCard'; 7 + import { ImageResponse } from '@ethercorps/sveltekit-og'; 8 + import { error } from '@sveltejs/kit'; 9 + import EventOgImage from './EventOgImage.svelte'; 10 + 11 + function formatDate(dateStr: string): string { 12 + const date = new Date(dateStr); 13 + const weekday = date.toLocaleDateString('en-US', { weekday: 'long' }); 14 + const month = date.toLocaleDateString('en-US', { month: 'long' }); 15 + const day = date.getDate(); 16 + return `${weekday}, ${month} ${day}`; 17 + } 18 + 19 + export async function GET({ params, platform, request }) { 20 + const { rkey } = params; 21 + 22 + const customDomain = request.headers.get('X-Custom-Domain')?.toLowerCase(); 23 + 24 + let actor: ActorIdentifier | undefined = params.actor; 25 + 26 + if (!actor) { 27 + const kv = platform?.env?.CUSTOM_DOMAINS; 28 + 29 + if (kv && customDomain) { 30 + try { 31 + const did = await kv.get(customDomain); 32 + if (did) actor = did as ActorIdentifier; 33 + } catch (error) { 34 + console.error('failed to get custom domain kv', error); 35 + } 36 + } else { 37 + actor = publicEnv.PUBLIC_HANDLE as ActorIdentifier; 38 + } 39 + } 40 + 41 + const did = isHandle(actor) ? await resolveHandle({ handle: actor }) : actor; 42 + 43 + if (!did || !rkey) { 44 + throw error(404, 'Event not found'); 45 + } 46 + 47 + let eventData: EventData; 48 + 49 + try { 50 + const eventResponse = await fetch( 51 + `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}` 52 + ); 53 + 54 + if (!eventResponse.ok) { 55 + throw error(404, 'Event not found'); 56 + } 57 + 58 + eventData = await eventResponse.json(); 59 + } catch (e) { 60 + if (e && typeof e === 'object' && 'status' in e) throw e; 61 + throw error(404, 'Event not found'); 62 + } 63 + 64 + const dateStr = formatDate(eventData.startsAt); 65 + 66 + let thumbnailUrl: string | null = null; 67 + if (eventData.media && eventData.media.length > 0) { 68 + const media = eventData.media.find((m) => m.role === 'thumbnail'); 69 + if (media?.content) { 70 + thumbnailUrl = getCDNImageBlobUrl({ did, blob: media.content, type: 'jpeg' }) ?? null; 71 + } 72 + } 73 + 74 + return new ImageResponse( 75 + EventOgImage, 76 + { width: 1200, height: 630, debug: false }, 77 + { 78 + name: eventData.name, 79 + dateStr, 80 + thumbnailUrl, 81 + rkey 82 + } 83 + ); 84 + }
+63
src/routes/[[actor=actor]]/e/[rkey]/og.png/EventOgImage.svelte
··· 1 + <script lang="ts"> 2 + import Avatar from 'svelte-boring-avatars'; 3 + 4 + let { 5 + name, 6 + dateStr, 7 + thumbnailUrl, 8 + rkey 9 + }: { 10 + name: string; 11 + dateStr: string; 12 + thumbnailUrl: string | null; 13 + rkey: string; 14 + } = $props(); 15 + </script> 16 + 17 + <div class="flex h-full w-full bg-neutral-900 p-0"> 18 + <div class="flex h-full shrink-0 items-center px-8"> 19 + <div class="flex overflow-hidden rounded-3xl"> 20 + {#if thumbnailUrl} 21 + <img src={thumbnailUrl} alt={name} width="420" height="420" style="object-fit: cover;" /> 22 + {:else} 23 + <Avatar 24 + size={420} 25 + name={rkey} 26 + variant="marble" 27 + colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 28 + square 29 + /> 30 + {/if} 31 + </div> 32 + </div> 33 + 34 + <div class="flex min-w-0 flex-1 flex-col justify-center p-12"> 35 + <h1 36 + class="text-7xl leading-tight font-bold text-neutral-50" 37 + style="display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word;" 38 + > 39 + {name} 40 + </h1> 41 + 42 + <div class="mt-8 flex items-center"> 43 + <svg 44 + width="28" 45 + height="28" 46 + viewBox="0 0 24 24" 47 + fill="none" 48 + xmlns="http://www.w3.org/2000/svg" 49 + > 50 + <path 51 + d="M8 2v3M16 2v3M3.5 9.09h17M21 8.5v8c0 3-1.5 5-5 5H8c-3.5 0-5-2-5-5v-8c0-3 1.5-5 5-5h8c3.5 0 5 2 5 5Z" 52 + stroke="#a3a3a3" 53 + stroke-width="1.5" 54 + stroke-miterlimit="10" 55 + stroke-linecap="round" 56 + stroke-linejoin="round" 57 + /> 58 + </svg> 59 + <span class="ml-3 text-2xl text-neutral-300">{dateStr}</span> 60 + </div> 61 + 62 + </div> 63 + </div>