powerpointproto slides.waow.tech
slides

add link previews and improve public view

- cloudflare pages function for OG meta tags on /view routes
- detects social bots and returns dynamic preview HTML
- link author handle to their bluesky profile
- add "create your own" link to homepage

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

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

+161 -1
+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 + }
+27 -1
src/routes/view/[did]/[rkey]/+page.svelte
··· 117 117 </div> 118 118 119 119 <div class="controls"> 120 + <span class="create-link"><a href="/">create your own</a></span> 120 121 <button onclick={prevSlide} disabled={currentSlide === 0}>←</button> 121 122 <span class="slide-counter">{currentSlide + 1} / {deck.slides.length}</span> 122 123 <button onclick={nextSlide} disabled={currentSlide >= deck.slides.length - 1}>→</button> 123 - <span class="author">by @{handle || data.did}</span> 124 + <span class="author">by <a href="https://bsky.app/profile/{handle || data.did}" target="_blank" rel="noopener">@{handle || data.did}</a></span> 124 125 </div> 125 126 </div> 126 127 {/if} ··· 254 255 right: 16px; 255 256 font-size: 13px; 256 257 color: #666; 258 + } 259 + 260 + .author a { 261 + color: #888; 262 + text-decoration: none; 263 + } 264 + 265 + .author a:hover { 266 + color: #fff; 267 + text-decoration: underline; 268 + } 269 + 270 + .create-link { 271 + position: absolute; 272 + left: 16px; 273 + font-size: 13px; 274 + } 275 + 276 + .create-link a { 277 + color: var(--accent, #6366f1); 278 + text-decoration: none; 279 + } 280 + 281 + .create-link a:hover { 282 + text-decoration: underline; 257 283 } 258 284 </style>