your personal website on atproto - mirror blento.app

first version

Florian ea22373f 0fdc4eea

+415 -2
+1
package.json
··· 84 84 "qr-code-styling": "^1.8.6", 85 85 "react-grid-layout": "^2.2.2", 86 86 "simple-icons": "^16.6.0", 87 + "svelte-boring-avatars": "^1.2.6", 87 88 "svelte-sonner": "^1.0.7", 88 89 "tailwind-merge": "^3.4.0", 89 90 "tailwind-variants": "^3.2.2",
+8
pnpm-lock.yaml
··· 140 140 simple-icons: 141 141 specifier: ^16.6.0 142 142 version: 16.6.0 143 + svelte-boring-avatars: 144 + specifier: ^1.2.6 145 + version: 1.2.6 143 146 svelte-sonner: 144 147 specifier: ^1.0.7 145 148 version: 1.0.7(svelte@5.48.0) ··· 2791 2794 supports-color@7.2.0: 2792 2795 resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} 2793 2796 engines: {node: '>=8'} 2797 + 2798 + svelte-boring-avatars@1.2.6: 2799 + resolution: {integrity: sha512-8+Z1DhsMUVI/V/5ik00Arw0PgbJcMdhTXq3YGqccBc5bYFeceCtMEMB0aWGhi8xFV+0aqZbWvS89Hcj16LlfHA==, tarball: https://registry.npmjs.org/svelte-boring-avatars/-/svelte-boring-avatars-1.2.6.tgz} 2794 2800 2795 2801 svelte-check@4.3.5: 2796 2802 resolution: {integrity: sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==} ··· 5609 5615 supports-color@7.2.0: 5610 5616 dependencies: 5611 5617 has-flag: 4.0.0 5618 + 5619 + svelte-boring-avatars@1.2.6: {} 5612 5620 5613 5621 svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.48.0)(typescript@5.9.3): 5614 5622 dependencies:
+5 -2
src/lib/atproto/methods.ts
··· 122 122 123 123 const response = await getDetailedProfile(data); 124 124 125 + const avatar = blentoProfile?.value?.icon 126 + ? getCDNImageBlobUrl({ did: data?.did, blob: blentoProfile?.value?.icon }) 127 + : response?.avatar; 128 + 125 129 return { 126 130 did: data.did, 127 131 handle: response?.handle, 128 132 displayName: blentoProfile?.value?.name || response?.displayName || response?.handle, 129 - avatar: (getCDNImageBlobUrl({ did: data?.did, blob: blentoProfile?.value?.icon }) || 130 - response?.avatar) as `${string}:${string}`, 133 + avatar: avatar as `${string}:${string}`, 131 134 hasBlento: Boolean(blentoProfile.value), 132 135 url: blentoProfile?.value?.url as string | undefined 133 136 };
+30
src/lib/cache.ts
··· 1 1 import type { ActorIdentifier, Did } from '@atcute/lexicons'; 2 2 import { isDid } from '@atcute/lexicons/syntax'; 3 3 import type { KVNamespace } from '@cloudflare/workers-types'; 4 + import { getBlentoOrBskyProfile } from '$lib/atproto/methods'; 4 5 5 6 /** TTL in seconds for each cache namespace */ 6 7 const NAMESPACE_TTL = { ··· 10 11 'gh-contrib': 60 * 60 * 12, // 12 hours 11 12 lastfm: 60 * 60, // 1 hour (default, overridable per-put) 12 13 npmx: 60 * 60 * 12, // 12 hours 14 + profile: 60 * 60 * 24, // 24 hours 13 15 meta: 0 // no auto-expiry 14 16 } as const; 15 17 ··· 89 91 async resolveHandle(did: Did): Promise<string | null> { 90 92 return this.get('identity', `d:${did}`); 91 93 } 94 + 95 + // === Profile cache (did → profile data) === 96 + async getProfile(did: Did): Promise<CachedProfile> { 97 + const cached = await this.getJSON<CachedProfile>('profile', did); 98 + if (cached) return cached; 99 + 100 + const profile = await getBlentoOrBskyProfile({ did }); 101 + const data: CachedProfile = { 102 + did: profile.did as string, 103 + handle: profile.handle as string, 104 + displayName: profile.displayName as string | undefined, 105 + avatar: profile.avatar as string | undefined, 106 + hasBlento: profile.hasBlento, 107 + url: profile.url 108 + }; 109 + 110 + await this.putJSON('profile', did, data); 111 + return data; 112 + } 92 113 } 114 + 115 + export type CachedProfile = { 116 + did: string; 117 + handle: string; 118 + displayName?: string; 119 + avatar?: string; 120 + hasBlento: boolean; 121 + url?: string; 122 + }; 93 123 94 124 export function createCache(platform?: App.Platform): CacheService | undefined { 95 125 const kv = platform?.env?.USER_DATA_CACHE;
+4
src/lib/cards/social/EventCard/index.ts
··· 33 33 height: number; 34 34 }; 35 35 }>; 36 + uris?: Array<{ 37 + uri: string; 38 + name?: string; 39 + }>; 36 40 countGoing?: number; 37 41 countInterested?: number; 38 42 url: string;
+59
src/routes/[[actor=actor]]/e/[rkey]/+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 { Did } from '@atcute/lexicons'; 7 + 8 + export async function load({ params, platform }) { 9 + const { rkey } = params; 10 + const did = isHandle(params.actor) ? await resolveHandle({ handle: params.actor }) : params.actor; 11 + 12 + if (!did || !rkey) { 13 + throw error(404, 'Event not found'); 14 + } 15 + 16 + try { 17 + const cache = createCache(platform); 18 + 19 + console.log( 20 + `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}` 21 + ); 22 + 23 + const [eventResponse, hostProfile] = await Promise.all([ 24 + fetch( 25 + `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}` 26 + ), 27 + cache 28 + ? cache.getProfile(did as Did).catch(() => null) 29 + : getBlentoOrBskyProfile({ did: did as Did }) 30 + .then( 31 + (p): CachedProfile => ({ 32 + did: p.did as string, 33 + handle: p.handle as string, 34 + displayName: p.displayName as string | undefined, 35 + avatar: p.avatar as string | undefined, 36 + hasBlento: p.hasBlento, 37 + url: p.url 38 + }) 39 + ) 40 + .catch(() => null) 41 + ]); 42 + 43 + if (!eventResponse.ok) { 44 + throw error(404, 'Event not found'); 45 + } 46 + 47 + const eventData: EventData = await eventResponse.json(); 48 + 49 + return { 50 + eventData, 51 + did, 52 + rkey, 53 + hostProfile: hostProfile ?? null 54 + }; 55 + } catch (e) { 56 + if (e && typeof e === 'object' && 'status' in e) throw e; 57 + throw error(404, 'Event not found'); 58 + } 59 + }
+308
src/routes/[[actor=actor]]/e/[rkey]/+page.svelte
··· 1 + <script lang="ts"> 2 + import type { EventData } from '$lib/cards/social/EventCard'; 3 + import { Avatar as FoxAvatar, Badge } from '@foxui/core'; 4 + import Avatar from 'svelte-boring-avatars'; 5 + 6 + let { data } = $props(); 7 + 8 + let eventData: EventData = $derived(data.eventData); 9 + let did: string = $derived(data.did); 10 + let rkey: string = $derived(data.rkey); 11 + let hostProfile = $derived(data.hostProfile); 12 + 13 + let hostUrl = $derived( 14 + hostProfile?.hasBlento 15 + ? `/${hostProfile.handle}` 16 + : `https://bsky.app/profile/${hostProfile?.handle || did}` 17 + ); 18 + 19 + let startDate = $derived(new Date(eventData.startsAt)); 20 + let endDate = $derived(eventData.endsAt ? new Date(eventData.endsAt) : null); 21 + 22 + function formatMonth(date: Date): string { 23 + return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); 24 + } 25 + 26 + function formatDay(date: Date): number { 27 + return date.getDate(); 28 + } 29 + 30 + function formatWeekday(date: Date): string { 31 + return date.toLocaleDateString('en-US', { weekday: 'long' }); 32 + } 33 + 34 + function formatFullDate(date: Date): string { 35 + const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' }; 36 + if (date.getFullYear() !== new Date().getFullYear()) { 37 + options.year = 'numeric'; 38 + } 39 + return date.toLocaleDateString('en-US', options); 40 + } 41 + 42 + function formatTime(date: Date): string { 43 + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); 44 + } 45 + 46 + function getModeLabel(mode: string): string { 47 + if (mode.includes('virtual')) return 'Virtual'; 48 + if (mode.includes('hybrid')) return 'Hybrid'; 49 + if (mode.includes('inperson')) return 'In-Person'; 50 + return 'Event'; 51 + } 52 + 53 + function getModeColor(mode: string): string { 54 + if (mode.includes('virtual')) return 'cyan'; 55 + if (mode.includes('hybrid')) return 'purple'; 56 + if (mode.includes('inperson')) return 'amber'; 57 + return 'gray'; 58 + } 59 + 60 + function getLocationString(locations: EventData['locations']): string | undefined { 61 + if (!locations || locations.length === 0) return undefined; 62 + 63 + const loc = locations.find((v => v.$type === "community.lexicon.location.address")); 64 + if (!loc) return undefined; 65 + 66 + // Handle both flat location objects (name, street, locality, country) 67 + // and nested address objects 68 + const flat = loc as Record<string, unknown>; 69 + const nested = loc.address; 70 + 71 + const street = (flat.street as string) || undefined; 72 + const locality = (flat.locality as string) || nested?.locality; 73 + const region = (flat.region as string) || nested?.region; 74 + 75 + const parts = [street, locality, region].filter(Boolean); 76 + return parts.length > 0 ? parts.join(', ') : undefined; 77 + } 78 + 79 + let location = $derived(getLocationString(eventData.locations)); 80 + 81 + let headerImage = $derived.by(() => { 82 + if (!eventData.media || eventData.media.length === 0) return null; 83 + const media = eventData.media.find((m) => m.role === 'thumbnail'); 84 + if (!media?.content?.ref?.$link) return null; 85 + return { 86 + url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${media.content.ref.$link}@jpeg`, 87 + alt: media.alt || eventData.name 88 + }; 89 + }); 90 + 91 + let eventUrl = $derived(eventData.url || `https://smokesignal.events/${did}/${rkey}`); 92 + </script> 93 + 94 + <svelte:head> 95 + <title>{eventData.name}</title> 96 + <meta name="description" content={eventData.description || `Event: ${eventData.name}`} /> 97 + </svelte:head> 98 + 99 + <div class="bg-base-50 dark:bg-base-950 min-h-screen px-4 py-8 sm:py-12"> 100 + <div class="mx-auto max-w-4xl"> 101 + <!-- Two-column layout: image left, details right --> 102 + <div class="flex flex-col gap-8 md:flex-row md:gap-10"> 103 + <!-- Left column: image --> 104 + <div class="shrink-0 md:w-56 lg:w-64 max-w-sm mx-auto md:max-w-none"> 105 + {#if headerImage} 106 + <img 107 + src={headerImage.url} 108 + alt={headerImage.alt} 109 + class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 110 + /> 111 + {:else} 112 + <div 113 + class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border [&>svg]:h-full [&>svg]:w-full" 114 + > 115 + <Avatar 116 + size={256} 117 + name={data.rkey} 118 + variant="marble" 119 + colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 120 + square 121 + /> 122 + </div> 123 + {/if} 124 + 125 + <!-- Hosted By section (below image, like Luma) --> 126 + <div class="mt-6"> 127 + <p 128 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 129 + > 130 + Hosted By 131 + </p> 132 + <a 133 + href={hostUrl} 134 + target={hostProfile?.hasBlento ? undefined : '_blank'} 135 + rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 136 + class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium hover:underline" 137 + > 138 + <FoxAvatar 139 + src={hostProfile?.avatar} 140 + alt={hostProfile?.displayName || hostProfile?.handle || did} 141 + class="size-8 shrink-0" 142 + /> 143 + <span class="truncate text-sm"> 144 + {hostProfile?.displayName || hostProfile?.handle || did} 145 + </span> 146 + </a> 147 + </div> 148 + 149 + {#if (eventData.countGoing && eventData.countGoing > 0) || (eventData.countInterested && eventData.countInterested > 0)} 150 + <div class="text-base-900 dark:text-base-100 mt-8 space-y-2.5 text-base font-medium"> 151 + {#if eventData.countGoing && eventData.countGoing > 0} 152 + <p>{eventData.countGoing} Going</p> 153 + {/if} 154 + {#if eventData.countInterested && eventData.countInterested > 0} 155 + <p>{eventData.countInterested} Interested</p> 156 + {/if} 157 + </div> 158 + {/if} 159 + 160 + {#if eventData.uris && eventData.uris.length > 0} 161 + <div class="mt-8"> 162 + <p 163 + class="text-base-500 dark:text-base-400 mb-2 text-xs font-semibold tracking-wider uppercase" 164 + > 165 + Links 166 + </p> 167 + <div class="space-y-1.5"> 168 + {#each eventData.uris as link} 169 + <a 170 + href={link.uri} 171 + target="_blank" 172 + rel="noopener noreferrer" 173 + class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex items-center gap-1.5 text-sm transition-colors" 174 + > 175 + <svg 176 + xmlns="http://www.w3.org/2000/svg" 177 + fill="none" 178 + viewBox="0 0 24 24" 179 + stroke-width="1.5" 180 + stroke="currentColor" 181 + class="size-3.5 shrink-0" 182 + > 183 + <path 184 + stroke-linecap="round" 185 + stroke-linejoin="round" 186 + d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 187 + /> 188 + </svg> 189 + <span class="truncate">{link.name || link.uri.replace(/^https?:\/\//, '')}</span> 190 + </a> 191 + {/each} 192 + </div> 193 + </div> 194 + {/if} 195 + </div> 196 + 197 + <!-- Right column: event details --> 198 + <div class="min-w-0 flex-1"> 199 + <h1 200 + class="text-base-900 dark:text-base-50 mb-2 text-4xl leading-tight font-bold sm:text-5xl" 201 + > 202 + {eventData.name} 203 + </h1> 204 + 205 + <!-- Mode badge --> 206 + {#if eventData.mode} 207 + <div class="mb-8"> 208 + <Badge size="md" variant={getModeColor(eventData.mode)} 209 + >{getModeLabel(eventData.mode)}</Badge 210 + > 211 + </div> 212 + {/if} 213 + 214 + <!-- Date row (Luma-style calendar icon) --> 215 + <div class="mb-4 flex items-center gap-4"> 216 + <div 217 + class="border-base-200 dark:border-base-700 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 218 + > 219 + <span class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold"> 220 + {formatMonth(startDate)} 221 + </span> 222 + <span class="text-base-900 dark:text-base-50 text-lg leading-tight font-bold"> 223 + {formatDay(startDate)} 224 + </span> 225 + </div> 226 + <div> 227 + <p class="text-base-900 dark:text-base-50 font-semibold"> 228 + {formatWeekday(startDate)}, {formatFullDate(startDate)} 229 + </p> 230 + <p class="text-base-500 dark:text-base-400 text-sm"> 231 + {formatTime(startDate)} 232 + {#if endDate} 233 + - {formatTime(endDate)}{/if} 234 + </p> 235 + </div> 236 + </div> 237 + 238 + <!-- Location row --> 239 + {#if location} 240 + <div class="mb-6 flex items-center gap-4"> 241 + <div 242 + class="border-base-200 dark:border-base-700 flex size-12 shrink-0 items-center justify-center rounded-xl border" 243 + > 244 + <svg 245 + xmlns="http://www.w3.org/2000/svg" 246 + fill="none" 247 + viewBox="0 0 24 24" 248 + stroke-width="1.5" 249 + stroke="currentColor" 250 + class="text-base-900 dark:text-base-200 size-5" 251 + > 252 + <path 253 + stroke-linecap="round" 254 + stroke-linejoin="round" 255 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 256 + /> 257 + <path 258 + stroke-linecap="round" 259 + stroke-linejoin="round" 260 + 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" 261 + /> 262 + </svg> 263 + </div> 264 + <p class="text-base-900 dark:text-base-50 font-semibold">{location}</p> 265 + </div> 266 + {/if} 267 + 268 + <!-- About Event --> 269 + {#if eventData.description} 270 + <div class="mt-8 mb-8"> 271 + <p 272 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 273 + > 274 + About 275 + </p> 276 + <p class="text-base-700 dark:text-base-300 leading-relaxed whitespace-pre-wrap"> 277 + {eventData.description} 278 + </p> 279 + </div> 280 + {/if} 281 + 282 + <!-- View on Smoke Signal link --> 283 + <a 284 + href={eventUrl} 285 + target="_blank" 286 + rel="noopener noreferrer" 287 + class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 inline-flex items-center gap-1.5 text-sm transition-colors" 288 + > 289 + View on Smoke Signal 290 + <svg 291 + xmlns="http://www.w3.org/2000/svg" 292 + fill="none" 293 + viewBox="0 0 24 24" 294 + stroke-width="2" 295 + stroke="currentColor" 296 + class="size-3.5" 297 + > 298 + <path 299 + stroke-linecap="round" 300 + stroke-linejoin="round" 301 + d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 302 + /> 303 + </svg> 304 + </a> 305 + </div> 306 + </div> 307 + </div> 308 + </div>