powerpointproto slides.waow.tech
slides

fix link previews with SSR and OG image generation

- server-side data loading for OG meta tags (bots don't run JS)
- generate OG images at /og/[did]/[rkey].png using Satori
- renders first slide with text, shapes, and images
- includes deck name, slide count, author in footer
- fallback card when slide data unavailable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+573 -148
bun.lockb

This is a binary file and will not be displayed.

-134
functions/view/[did]/[rkey].js
··· 1 - // Cloudflare Pages Function for /view/:did/:rkey 2 - // Injects OG meta tags for social media link previews 3 - 4 - const SITE_URL = 'https://slides.waow.tech'; 5 - const DECK_COLLECTION = 'tech.waow.slides.deck'; 6 - 7 - // Social media bot user agents 8 - const BOT_USER_AGENTS = [ 9 - 'Twitterbot', 10 - 'facebookexternalhit', 11 - 'LinkedInBot', 12 - 'Slackbot', 13 - 'Discordbot', 14 - 'TelegramBot', 15 - 'WhatsApp', 16 - 'Bluesky', 17 - ]; 18 - 19 - function isSocialBot(userAgent) { 20 - if (!userAgent) return false; 21 - return BOT_USER_AGENTS.some(bot => userAgent.includes(bot)); 22 - } 23 - 24 - // Resolve DID to PDS URL 25 - async function resolvePds(did) { 26 - try { 27 - if (did.startsWith('did:plc:')) { 28 - const res = await fetch(`https://plc.directory/${did}`); 29 - if (res.ok) { 30 - const doc = await res.json(); 31 - const pds = doc.service?.find(s => s.id === '#atproto_pds'); 32 - if (pds?.serviceEndpoint) return pds.serviceEndpoint; 33 - } 34 - } 35 - } catch {} 36 - return 'https://bsky.social'; 37 - } 38 - 39 - // Resolve DID to handle 40 - async function resolveHandle(did) { 41 - try { 42 - const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`); 43 - if (res.ok) { 44 - const data = await res.json(); 45 - return data.handle || did; 46 - } 47 - } catch {} 48 - return did; 49 - } 50 - 51 - // Fetch deck from PDS 52 - async function fetchDeck(did, rkey) { 53 - const pdsUrl = await resolvePds(did); 54 - const url = `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${DECK_COLLECTION}&rkey=${rkey}`; 55 - 56 - try { 57 - const res = await fetch(url); 58 - if (!res.ok) return null; 59 - const data = await res.json(); 60 - return data.value; 61 - } catch { 62 - return null; 63 - } 64 - } 65 - 66 - function generateOgHtml(deck, did, rkey, handle) { 67 - const title = deck.name || 'Untitled Presentation'; 68 - const slideCount = deck.slides?.length || 0; 69 - const description = `${slideCount} slide${slideCount !== 1 ? 's' : ''} by @${handle}`; 70 - const url = `${SITE_URL}/view/${did}/${rkey}`; 71 - 72 - return `<!DOCTYPE html> 73 - <html lang="en"> 74 - <head> 75 - <meta charset="utf-8"> 76 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 77 - <title>${title} | slides</title> 78 - 79 - <!-- Open Graph --> 80 - <meta property="og:type" content="website"> 81 - <meta property="og:title" content="${title}"> 82 - <meta property="og:description" content="${description}"> 83 - <meta property="og:url" content="${url}"> 84 - <meta property="og:site_name" content="slides"> 85 - 86 - <!-- Twitter Card --> 87 - <meta name="twitter:card" content="summary"> 88 - <meta name="twitter:title" content="${title}"> 89 - <meta name="twitter:description" content="${description}"> 90 - 91 - <!-- Redirect browsers to the actual page --> 92 - <meta http-equiv="refresh" content="0;url=${url}"> 93 - <link rel="canonical" href="${url}"> 94 - </head> 95 - <body> 96 - <p>Redirecting to <a href="${url}">${url}</a></p> 97 - </body> 98 - </html>`; 99 - } 100 - 101 - export async function onRequest(context) { 102 - const { request, params, next } = context; 103 - const { did, rkey } = params; 104 - 105 - const userAgent = request.headers.get('user-agent') || ''; 106 - 107 - // If not a social bot, pass through to the SPA 108 - if (!isSocialBot(userAgent)) { 109 - return next(); 110 - } 111 - 112 - try { 113 - const [deck, handle] = await Promise.all([ 114 - fetchDeck(did, rkey), 115 - resolveHandle(did), 116 - ]); 117 - 118 - if (!deck) { 119 - return next(); 120 - } 121 - 122 - const html = generateOgHtml(deck, did, rkey, handle); 123 - 124 - return new Response(html, { 125 - headers: { 126 - 'Content-Type': 'text/html;charset=UTF-8', 127 - 'Cache-Control': 'public, max-age=3600', 128 - }, 129 - }); 130 - } catch (error) { 131 - console.error('Error generating OG tags:', error); 132 - return next(); 133 - } 134 - }
+3 -1
package.json
··· 24 24 "dependencies": { 25 25 "@atcute/client": "^4.2.1", 26 26 "@atcute/identity-resolver": "^1.2.2", 27 - "@atcute/oauth-browser-client": "^2.0.3" 27 + "@atcute/oauth-browser-client": "^2.0.3", 28 + "@resvg/resvg-wasm": "^2.6.2", 29 + "satori": "^0.19.1" 28 30 } 29 31 }
+432
src/routes/og/[did]/[rkey]/+server.ts
··· 1 + import satori from "satori"; 2 + import { Resvg, initWasm } from "@resvg/resvg-wasm"; 3 + import type { RequestHandler } from "./$types"; 4 + 5 + const DECK_COLLECTION = "tech.waow.slides.deck"; 6 + const SLIDE_COLLECTION = "tech.waow.slides.slide"; 7 + 8 + let wasmInitialized = false; 9 + 10 + async function initResvg() { 11 + if (wasmInitialized) return; 12 + try { 13 + const wasmUrl = "https://unpkg.com/@aspect-dev/resvg-wasm@0.0.7/wasm/resvg.wasm"; 14 + const res = await fetch(wasmUrl); 15 + const wasm = await res.arrayBuffer(); 16 + await initWasm(wasm); 17 + wasmInitialized = true; 18 + } catch (e) { 19 + // May already be initialized 20 + wasmInitialized = true; 21 + } 22 + } 23 + 24 + async function resolvePds(did: string): Promise<string> { 25 + try { 26 + if (did.startsWith("did:plc:")) { 27 + const res = await fetch(`https://plc.directory/${did}`); 28 + if (res.ok) { 29 + const doc = await res.json(); 30 + const pds = doc.service?.find((s: { id: string; serviceEndpoint?: string }) => s.id === "#atproto_pds"); 31 + if (pds?.serviceEndpoint) return pds.serviceEndpoint; 32 + } 33 + } 34 + } catch {} 35 + return "https://bsky.social"; 36 + } 37 + 38 + async function resolveHandle(did: string): Promise<string> { 39 + try { 40 + const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`); 41 + if (res.ok) { 42 + const data = await res.json(); 43 + return data.handle || did; 44 + } 45 + } catch {} 46 + return did; 47 + } 48 + 49 + type Element = { 50 + type: string; 51 + x: number; 52 + y: number; 53 + width: number; 54 + height: number; 55 + content?: string; 56 + fontSize?: number; 57 + color?: string; 58 + fontFamily?: string; 59 + shapeType?: string; 60 + blob?: { ref: { $link: string }; mimeType: string }; 61 + }; 62 + 63 + type SlideRecord = { 64 + elements: Element[]; 65 + notes?: string; 66 + background?: string; 67 + }; 68 + 69 + type DeckRecord = { 70 + name: string; 71 + slides: Array<{ subject: { uri: string; cid: string } }>; 72 + }; 73 + 74 + async function fetchSlide(uri: string, pdsUrl: string): Promise<SlideRecord | null> { 75 + const parts = uri.split("/"); 76 + const rkey = parts.pop()!; 77 + const collection = parts.pop()!; 78 + const did = parts.slice(2).join("/"); 79 + 80 + try { 81 + const res = await fetch( 82 + `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${collection}&rkey=${rkey}` 83 + ); 84 + if (!res.ok) return null; 85 + const data = await res.json(); 86 + return data.value as SlideRecord; 87 + } catch { 88 + return null; 89 + } 90 + } 91 + 92 + async function fetchDeckWithFirstSlide(did: string, rkey: string) { 93 + const pdsUrl = (await resolvePds(did)).replace(/\/$/, ""); 94 + 95 + try { 96 + const res = await fetch( 97 + `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${DECK_COLLECTION}&rkey=${rkey}` 98 + ); 99 + if (!res.ok) return null; 100 + const data = await res.json(); 101 + const record = data.value as DeckRecord; 102 + 103 + let firstSlide: SlideRecord | null = null; 104 + if (record.slides?.length > 0) { 105 + firstSlide = await fetchSlide(record.slides[0].subject.uri, pdsUrl); 106 + } 107 + 108 + return { 109 + name: record.name, 110 + slideCount: record.slides?.length || 0, 111 + firstSlide, 112 + pdsUrl, 113 + did, 114 + }; 115 + } catch { 116 + return null; 117 + } 118 + } 119 + 120 + function getBlobUrl(did: string, cid: string, pdsUrl: string): string { 121 + return `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 122 + } 123 + 124 + // Render slide elements to Satori-compatible markup 125 + function renderSlideToSatori( 126 + slide: SlideRecord, 127 + deckName: string, 128 + handle: string, 129 + slideCount: number, 130 + did: string, 131 + pdsUrl: string 132 + ) { 133 + const width = 1200; 134 + const height = 630; 135 + const scale = (val: number) => (val / 1000) * (width * 0.85); 136 + const scaleY = (val: number) => (val / 1000) * (height * 0.75); 137 + 138 + const elements = slide.elements.map((el) => { 139 + const left = 60 + scale(el.x); 140 + const top = 60 + scaleY(el.y); 141 + const elWidth = scale(el.width); 142 + const elHeight = scaleY(el.height); 143 + 144 + if (el.type === "text") { 145 + return { 146 + type: "div", 147 + props: { 148 + style: { 149 + position: "absolute", 150 + left, 151 + top, 152 + width: elWidth, 153 + height: elHeight, 154 + display: "flex", 155 + alignItems: "center", 156 + justifyContent: "center", 157 + textAlign: "center", 158 + fontSize: Math.min((el.fontSize || 32) * 0.6, 48), 159 + color: el.color || "#ffffff", 160 + fontFamily: "Inter", 161 + }, 162 + children: el.content || "", 163 + }, 164 + }; 165 + } 166 + 167 + if (el.type === "shape") { 168 + let borderRadius = "8px"; 169 + if (el.shapeType === "ellipse") borderRadius = "50%"; 170 + 171 + return { 172 + type: "div", 173 + props: { 174 + style: { 175 + position: "absolute", 176 + left, 177 + top, 178 + width: elWidth, 179 + height: elHeight, 180 + backgroundColor: el.color || "#3b82f6", 181 + borderRadius, 182 + }, 183 + }, 184 + }; 185 + } 186 + 187 + if (el.type === "image" && el.blob) { 188 + const imgUrl = getBlobUrl(did, el.blob.ref.$link, pdsUrl); 189 + return { 190 + type: "img", 191 + props: { 192 + src: imgUrl, 193 + style: { 194 + position: "absolute", 195 + left, 196 + top, 197 + width: elWidth, 198 + height: elHeight, 199 + objectFit: "contain", 200 + }, 201 + }, 202 + }; 203 + } 204 + 205 + return null; 206 + }).filter(Boolean); 207 + 208 + return { 209 + type: "div", 210 + props: { 211 + style: { 212 + width, 213 + height, 214 + display: "flex", 215 + flexDirection: "column", 216 + backgroundColor: "#0f0f0f", 217 + fontFamily: "Inter", 218 + position: "relative", 219 + }, 220 + children: [ 221 + // Slide content area 222 + { 223 + type: "div", 224 + props: { 225 + style: { 226 + flex: 1, 227 + position: "relative", 228 + }, 229 + children: elements, 230 + }, 231 + }, 232 + // Footer with deck info 233 + { 234 + type: "div", 235 + props: { 236 + style: { 237 + display: "flex", 238 + justifyContent: "space-between", 239 + alignItems: "center", 240 + padding: "16px 24px", 241 + backgroundColor: "#000000", 242 + borderTop: "1px solid #222", 243 + }, 244 + children: [ 245 + { 246 + type: "div", 247 + props: { 248 + style: { 249 + display: "flex", 250 + alignItems: "center", 251 + gap: "12px", 252 + }, 253 + children: [ 254 + { 255 + type: "div", 256 + props: { 257 + style: { 258 + fontSize: 20, 259 + fontWeight: 600, 260 + color: "#ffffff", 261 + }, 262 + children: deckName, 263 + }, 264 + }, 265 + { 266 + type: "div", 267 + props: { 268 + style: { 269 + fontSize: 14, 270 + color: "#666", 271 + }, 272 + children: `${slideCount} slide${slideCount !== 1 ? "s" : ""}`, 273 + }, 274 + }, 275 + ], 276 + }, 277 + }, 278 + { 279 + type: "div", 280 + props: { 281 + style: { 282 + display: "flex", 283 + alignItems: "center", 284 + gap: "8px", 285 + }, 286 + children: [ 287 + { 288 + type: "div", 289 + props: { 290 + style: { 291 + fontSize: 14, 292 + color: "#888", 293 + }, 294 + children: `by @${handle}`, 295 + }, 296 + }, 297 + { 298 + type: "div", 299 + props: { 300 + style: { 301 + fontSize: 14, 302 + color: "#6366f1", 303 + fontWeight: 500, 304 + }, 305 + children: "slides.waow.tech", 306 + }, 307 + }, 308 + ], 309 + }, 310 + }, 311 + ], 312 + }, 313 + }, 314 + ], 315 + }, 316 + }; 317 + } 318 + 319 + // Fallback card when no slide data 320 + function renderFallbackCard(deckName: string, handle: string, slideCount: number) { 321 + return { 322 + type: "div", 323 + props: { 324 + style: { 325 + width: 1200, 326 + height: 630, 327 + display: "flex", 328 + flexDirection: "column", 329 + alignItems: "center", 330 + justifyContent: "center", 331 + backgroundColor: "#0f0f0f", 332 + fontFamily: "Inter", 333 + gap: "24px", 334 + }, 335 + children: [ 336 + { 337 + type: "div", 338 + props: { 339 + style: { 340 + fontSize: 64, 341 + fontWeight: 700, 342 + color: "#ffffff", 343 + textAlign: "center", 344 + maxWidth: "80%", 345 + }, 346 + children: deckName, 347 + }, 348 + }, 349 + { 350 + type: "div", 351 + props: { 352 + style: { 353 + fontSize: 24, 354 + color: "#888", 355 + }, 356 + children: `${slideCount} slide${slideCount !== 1 ? "s" : ""} by @${handle}`, 357 + }, 358 + }, 359 + { 360 + type: "div", 361 + props: { 362 + style: { 363 + fontSize: 20, 364 + color: "#6366f1", 365 + marginTop: "24px", 366 + }, 367 + children: "slides.waow.tech", 368 + }, 369 + }, 370 + ], 371 + }, 372 + }; 373 + } 374 + 375 + export const GET: RequestHandler = async ({ params }) => { 376 + const { did, rkey: rawRkey } = params; 377 + const rkey = rawRkey.replace(/\.png$/, ""); 378 + 379 + try { 380 + await initResvg(); 381 + 382 + const [deck, handle] = await Promise.all([ 383 + fetchDeckWithFirstSlide(did, rkey), 384 + resolveHandle(did), 385 + ]); 386 + 387 + if (!deck) { 388 + return new Response("Not found", { status: 404 }); 389 + } 390 + 391 + // Render slide or fallback 392 + const markup = deck.firstSlide 393 + ? renderSlideToSatori(deck.firstSlide, deck.name, handle, deck.slideCount, deck.did, deck.pdsUrl) 394 + : renderFallbackCard(deck.name, handle, deck.slideCount); 395 + 396 + // Generate SVG with Satori 397 + const svg = await satori(markup as any, { 398 + width: 1200, 399 + height: 630, 400 + fonts: [ 401 + { 402 + name: "Inter", 403 + data: await fetch("https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfMZhrib2Bg-4.ttf").then(r => r.arrayBuffer()), 404 + weight: 400, 405 + style: "normal", 406 + }, 407 + { 408 + name: "Inter", 409 + data: await fetch("https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuGKYMZhrib2Bg-4.ttf").then(r => r.arrayBuffer()), 410 + weight: 600, 411 + style: "normal", 412 + }, 413 + ], 414 + }); 415 + 416 + // Convert SVG to PNG 417 + const resvg = new Resvg(svg, { 418 + fitTo: { mode: "width", value: 1200 }, 419 + }); 420 + const png = resvg.render().asPng(); 421 + 422 + return new Response(png.buffer, { 423 + headers: { 424 + "Content-Type": "image/png", 425 + "Cache-Control": "public, max-age=86400, s-maxage=86400", 426 + }, 427 + }); 428 + } catch (error) { 429 + console.error("OG image generation error:", error); 430 + return new Response("Error generating image", { status: 500 }); 431 + } 432 + };
+113
src/routes/view/[did]/[rkey]/+page.server.ts
··· 1 + const DECK_COLLECTION = "tech.waow.slides.deck"; 2 + const SLIDE_COLLECTION = "tech.waow.slides.slide"; 3 + 4 + async function resolvePds(did: string): Promise<string> { 5 + try { 6 + if (did.startsWith("did:plc:")) { 7 + const res = await fetch(`https://plc.directory/${did}`); 8 + if (res.ok) { 9 + const doc = await res.json(); 10 + const pds = doc.service?.find((s: { id: string; serviceEndpoint?: string }) => s.id === "#atproto_pds"); 11 + if (pds?.serviceEndpoint) return pds.serviceEndpoint; 12 + } 13 + } 14 + } catch {} 15 + return "https://bsky.social"; 16 + } 17 + 18 + async function resolveHandle(did: string): Promise<string> { 19 + try { 20 + const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`); 21 + if (res.ok) { 22 + const data = await res.json(); 23 + return data.handle || did; 24 + } 25 + } catch {} 26 + return did; 27 + } 28 + 29 + type SlideRecord = { 30 + elements: Array<{ 31 + type: string; 32 + x: number; 33 + y: number; 34 + width: number; 35 + height: number; 36 + content?: string; 37 + fontSize?: number; 38 + color?: string; 39 + fontFamily?: string; 40 + shapeType?: string; 41 + blob?: { ref: { $link: string }; mimeType: string }; 42 + }>; 43 + notes?: string; 44 + background?: string; 45 + }; 46 + 47 + type DeckRecord = { 48 + name: string; 49 + slides: Array<{ subject: { uri: string; cid: string } }>; 50 + createdAt: string; 51 + }; 52 + 53 + async function fetchSlide(uri: string, pdsUrl: string): Promise<SlideRecord | null> { 54 + const parts = uri.split("/"); 55 + const rkey = parts.pop()!; 56 + const collection = parts.pop()!; 57 + const did = parts.slice(2).join("/"); 58 + 59 + try { 60 + const res = await fetch( 61 + `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${collection}&rkey=${rkey}` 62 + ); 63 + if (!res.ok) return null; 64 + const data = await res.json(); 65 + return data.value as SlideRecord; 66 + } catch { 67 + return null; 68 + } 69 + } 70 + 71 + async function fetchDeck(did: string, rkey: string) { 72 + const pdsUrl = (await resolvePds(did)).replace(/\/$/, ""); 73 + 74 + try { 75 + const res = await fetch( 76 + `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${DECK_COLLECTION}&rkey=${rkey}` 77 + ); 78 + if (!res.ok) return null; 79 + const data = await res.json(); 80 + const record = data.value as DeckRecord; 81 + 82 + // Fetch first slide for preview 83 + let firstSlide: SlideRecord | null = null; 84 + if (record.slides?.length > 0) { 85 + firstSlide = await fetchSlide(record.slides[0].subject.uri, pdsUrl); 86 + } 87 + 88 + return { 89 + name: record.name, 90 + slideCount: record.slides?.length || 0, 91 + firstSlide, 92 + pdsUrl, 93 + }; 94 + } catch { 95 + return null; 96 + } 97 + } 98 + 99 + export const load = async ({ params, url }) => { 100 + const { did, rkey } = params; 101 + const slide = parseInt(url.searchParams.get("slide") || "0", 10); 102 + 103 + const [deck, handle] = await Promise.all([fetchDeck(did, rkey), resolveHandle(did)]); 104 + 105 + return { 106 + did, 107 + rkey, 108 + slide: isNaN(slide) ? 0 : slide, 109 + handle, 110 + deck, 111 + ogImageUrl: `https://slides.waow.tech/og/${did}/${rkey}.png`, 112 + }; 113 + };
+25 -5
src/routes/view/[did]/[rkey]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from "svelte"; 3 - import { getPublicDeck, resolveHandle, resolveDeckBlobs } from "$lib/api"; 3 + import { getPublicDeck, resolveDeckBlobs } from "$lib/api"; 4 4 import type { Deck } from "$lib/api"; 5 5 6 6 let { data } = $props(); ··· 8 8 let error = $state<string | null>(null); 9 9 let deck = $state<Deck | null>(null); 10 10 let currentSlide = $state(0); 11 - let handle = $state<string | null>(null); 11 + 12 + // Server provides initial data for OG tags 13 + const deckName = data.deck?.name || "presentation"; 14 + const slideCount = data.deck?.slideCount || 0; 15 + const handle = data.handle; 12 16 13 17 onMount(async () => { 18 + // Fetch full deck client-side for interactivity 14 19 const result = await getPublicDeck(data.did, data.rkey); 15 20 if (result) { 16 21 deck = resolveDeckBlobs(result, data.did); 17 22 currentSlide = Math.min(data.slide, deck.slides.length - 1); 18 - handle = await resolveHandle(data.did); 19 23 loading = false; 20 24 } else { 21 25 error = "deck not found"; ··· 71 75 <svelte:window onkeydown={handleKeydown} /> 72 76 73 77 <svelte:head> 74 - <title>{deck?.name || "presentation"} - slides</title> 78 + <title>{deckName} - slides</title> 79 + 80 + <!-- Open Graph --> 81 + <meta property="og:type" content="website" /> 82 + <meta property="og:title" content={deckName} /> 83 + <meta property="og:description" content={`${slideCount} slide${slideCount !== 1 ? 's' : ''} by @${handle}`} /> 84 + <meta property="og:url" content={`https://slides.waow.tech/view/${data.did}/${data.rkey}`} /> 85 + <meta property="og:site_name" content="slides" /> 86 + <meta property="og:image" content={data.ogImageUrl} /> 87 + <meta property="og:image:width" content="1200" /> 88 + <meta property="og:image:height" content="630" /> 89 + 90 + <!-- Twitter Card --> 91 + <meta name="twitter:card" content="summary_large_image" /> 92 + <meta name="twitter:title" content={deckName} /> 93 + <meta name="twitter:description" content={`${slideCount} slide${slideCount !== 1 ? 's' : ''} by @${handle}`} /> 94 + <meta name="twitter:image" content={data.ogImageUrl} /> 75 95 </svelte:head> 76 96 77 97 {#if loading} ··· 121 141 <button onclick={prevSlide} disabled={currentSlide === 0}>←</button> 122 142 <span class="slide-counter">{currentSlide + 1} / {deck.slides.length}</span> 123 143 <button onclick={nextSlide} disabled={currentSlide >= deck.slides.length - 1}>→</button> 124 - <span class="author">by <a href="https://bsky.app/profile/{handle || data.did}" target="_blank" rel="noopener">@{handle || data.did}</a></span> 144 + <span class="author">by <a href="https://bsky.app/profile/{handle}" target="_blank" rel="noopener">@{handle}</a></span> 125 145 </div> 126 146 </div> 127 147 {/if}
-8
src/routes/view/[did]/[rkey]/+page.ts
··· 1 - export const load = ({ params, url }) => { 2 - const slide = parseInt(url.searchParams.get("slide") || "0", 10); 3 - return { 4 - did: params.did, 5 - rkey: params.rkey, 6 - slide: isNaN(slide) ? 0 : slide, 7 - }; 8 - };