powerpointproto slides.waow.tech
slides

add public profile pages, thumbnail support, and mobile improvements

- add /@handle route showing user's public decks
- add thumbnail blob field to deck records for link previews
- simplify OG endpoint to redirect to thumbnail blob URL
- add thumbnail upload button to toolbar
- show thumbnail previews on homepage deck list
- add touch/swipe navigation for mobile presentations
- add tap zones (left/right) for slide navigation
- scale font sizes relative to canvas width on mobile
- improve mobile controls layout (hide "create your own", reposition author)
- use dynamic viewport height (100dvh) for mobile browsers
- link author to profile page instead of external Bluesky

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

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

+731 -389
bun.lockb

This is a binary file and will not be displayed.

+6
lexicons/tech/waow/slides/deck.json
··· 33 33 "updatedAt": { 34 34 "type": "string", 35 35 "format": "datetime" 36 + }, 37 + "thumbnail": { 38 + "type": "blob", 39 + "description": "Thumbnail image for link previews.", 40 + "accept": ["image/png", "image/jpeg", "image/webp"], 41 + "maxSize": 1000000 36 42 } 37 43 } 38 44 }
+1 -3
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", 28 - "@resvg/resvg-wasm": "^2.6.2", 29 - "satori": "^0.19.1" 27 + "@atcute/oauth-browser-client": "^2.0.3" 30 28 } 31 29 }
+63 -2
src/lib/api/deck.ts
··· 1 - import { currentDid, getRpc, publicFetch, resolvePdsUrl } from "./client"; 1 + import { currentDid, getRpc, getPdsUrl, publicFetch, resolvePdsUrl } from "./client"; 2 2 import { createSlide, deleteSlide, getPublicSlide, getSlide, updateSlide } from "./slide"; 3 + import { getBlobUrl } from "./blob"; 3 4 import { DECK, type Deck, type DeckRecord, type Slide, type SlideRef } from "./types"; 4 5 5 6 const rkeyFromUri = (uri: string): string => uri.split("/").pop()!; 6 7 7 - export const createDeck = async (name: string, slides: Slide[]): Promise<string | null> => { 8 + export const createDeck = async (name: string, slides: Slide[], thumbnail?: Deck["thumbnail"]): Promise<string | null> => { 8 9 if (!currentDid) return null; 9 10 10 11 const rpc = getRpc(); ··· 24 25 slides: slideRefs, 25 26 createdAt: now, 26 27 updatedAt: now, 28 + ...(thumbnail && { thumbnail }), 27 29 }; 28 30 29 31 const res = await rpc.post("com.atproto.repo.createRecord", { ··· 90 92 slides: slideRefs, 91 93 createdAt: existingRecord.createdAt, 92 94 updatedAt: new Date().toISOString(), 95 + // preserve existing thumbnail or use new one if provided 96 + ...(deck.thumbnail !== undefined 97 + ? (deck.thumbnail ? { thumbnail: deck.thumbnail } : {}) 98 + : (existingRecord.thumbnail ? { thumbnail: existingRecord.thumbnail } : {})), 93 99 }; 94 100 95 101 const res = await rpc.post("com.atproto.repo.putRecord", { ··· 150 156 if (slide) slides.push(slide); 151 157 } 152 158 159 + // resolve thumbnail URL if present 160 + const thumbnailUrl = record.thumbnail 161 + ? getBlobUrl(repo, record.thumbnail.ref.$link, getPdsUrl() || undefined) 162 + : undefined; 163 + 153 164 return { 154 165 uri: res.data.uri, 155 166 repo, ··· 158 169 slides, 159 170 createdAt: record.createdAt, 160 171 updatedAt: record.updatedAt, 172 + thumbnail: record.thumbnail, 173 + thumbnailUrl, 161 174 }; 162 175 }; 163 176 ··· 171 184 172 185 if (!res.ok) return []; 173 186 187 + const pdsUrl = getPdsUrl() || undefined; 188 + 174 189 // eslint-disable-next-line @typescript-eslint/no-explicit-any 175 190 return res.data.records.map((r: any) => { 176 191 const val = r.value as DeckRecord; 177 192 const rkey = r.uri.split("/").pop()!; 193 + const thumbnailUrl = val.thumbnail 194 + ? getBlobUrl(currentDid!, val.thumbnail.ref.$link, pdsUrl) 195 + : undefined; 178 196 // return shallow deck with slide count (slides not resolved yet) 179 197 return { 180 198 uri: r.uri, ··· 185 203 slideCount: val.slides.length, // for display before full resolution 186 204 createdAt: val.createdAt, 187 205 updatedAt: val.updatedAt, 206 + thumbnail: val.thumbnail, 207 + thumbnailUrl, 208 + }; 209 + }); 210 + }; 211 + 212 + // list public decks for any user (no auth required) 213 + export const listPublicDecks = async (did: string): Promise<Deck[]> => { 214 + const pdsUrl = await resolvePdsUrl(did); 215 + 216 + const data = await publicFetch(pdsUrl, "com.atproto.repo.listRecords", { 217 + repo: did, 218 + collection: DECK, 219 + limit: "100", 220 + }); 221 + 222 + if (!data?.records) return []; 223 + 224 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 225 + return data.records.map((r: any) => { 226 + const val = r.value as DeckRecord; 227 + const rkey = r.uri.split("/").pop()!; 228 + const thumbnailUrl = val.thumbnail 229 + ? getBlobUrl(did, val.thumbnail.ref.$link, pdsUrl) 230 + : undefined; 231 + return { 232 + uri: r.uri, 233 + repo: did, 234 + rkey, 235 + name: val.name, 236 + slides: [], 237 + slideCount: val.slides.length, 238 + createdAt: val.createdAt, 239 + updatedAt: val.updatedAt, 240 + thumbnail: val.thumbnail, 241 + thumbnailUrl, 188 242 }; 189 243 }); 190 244 }; ··· 210 264 if (slide) slides.push(slide); 211 265 } 212 266 267 + // resolve thumbnail URL if present 268 + const thumbnailUrl = record.thumbnail 269 + ? getBlobUrl(did, record.thumbnail.ref.$link, pdsUrl) 270 + : undefined; 271 + 213 272 return { 214 273 uri: data.uri, 215 274 repo: did, ··· 218 277 slides, 219 278 createdAt: record.createdAt, 220 279 updatedAt: record.updatedAt, 280 + thumbnail: record.thumbnail, 281 + thumbnailUrl, 221 282 }; 222 283 };
+3
src/lib/api/types.ts
··· 55 55 slides: SlideRef[]; 56 56 createdAt: string; 57 57 updatedAt?: string; 58 + thumbnail?: BlobRef; 58 59 }; 59 60 60 61 // deck with resolved slides for UI ··· 67 68 slideCount?: number; // for shallow decks before slides are resolved 68 69 createdAt: string; 69 70 updatedAt?: string; 71 + thumbnail?: BlobRef; 72 + thumbnailUrl?: string; // resolved blob URL for display 70 73 };
+86
src/lib/components/Toolbar.svelte
··· 21 21 let statusType = $state<"info" | "success" | "error">("info"); 22 22 let settingsOpen = $state(false); 23 23 let fileInputRef: HTMLInputElement | null = $state(null); 24 + let thumbnailInputRef: HTMLInputElement | null = $state(null); 24 25 let uploadingImage = $state(false); 26 + let uploadingThumbnail = $state(false); 25 27 let copied = $state(false); 26 28 27 29 const handleShare = async () => { ··· 208 210 editorState.deck.name = (e.target as HTMLInputElement).value; 209 211 }; 210 212 213 + const handleThumbnailUpload = async (e: Event) => { 214 + const input = e.target as HTMLInputElement; 215 + const file = input.files?.[0]; 216 + if (!file || !editorState.deck || !auth.loggedIn) return; 217 + 218 + uploadingThumbnail = true; 219 + status = "uploading thumbnail..."; 220 + statusType = "info"; 221 + 222 + try { 223 + const arrayBuffer = await file.arrayBuffer(); 224 + const data = new Uint8Array(arrayBuffer); 225 + const blobRef = await uploadBlob(data, file.type); 226 + 227 + if (blobRef) { 228 + editorState.deck.thumbnail = blobRef; 229 + // save the deck to persist the thumbnail 230 + if (editorState.deck.rkey) { 231 + await updateDeck(editorState.deck.rkey, { 232 + thumbnail: blobRef, 233 + }); 234 + status = "thumbnail set!"; 235 + statusType = "success"; 236 + } else { 237 + status = "thumbnail set (save deck to persist)"; 238 + statusType = "success"; 239 + } 240 + } else { 241 + status = "failed to upload thumbnail"; 242 + statusType = "error"; 243 + } 244 + } catch (err) { 245 + console.error("thumbnail upload error:", err); 246 + status = "failed to set thumbnail"; 247 + statusType = "error"; 248 + } finally { 249 + uploadingThumbnail = false; 250 + input.value = ""; 251 + setTimeout(() => (status = ""), 2000); 252 + } 253 + }; 254 + 211 255 const handleKeydown = (e: KeyboardEvent) => { 212 256 if (e.key === "Enter") { 213 257 handleLogin(); ··· 348 392 </button> 349 393 {#if editorState.deck.rkey} 350 394 <button 395 + class="thumbnail-btn" 396 + class:has-thumbnail={editorState.deck.thumbnail} 397 + onclick={() => thumbnailInputRef?.click()} 398 + disabled={uploadingThumbnail} 399 + title={editorState.deck.thumbnail ? "Change thumbnail" : "Set thumbnail for link previews"} 400 + > 401 + {#if uploadingThumbnail} 402 + ... 403 + {:else} 404 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 405 + <rect x="2" y="4" width="20" height="12" rx="2"/> 406 + <line x1="6" y1="20" x2="18" y2="20"/> 407 + <line x1="9" y1="16" x2="9" y2="20"/> 408 + <line x1="15" y1="16" x2="15" y2="20"/> 409 + </svg> 410 + {/if} 411 + </button> 412 + <input 413 + type="file" 414 + accept="image/png,image/jpeg,image/webp" 415 + bind:this={thumbnailInputRef} 416 + onchange={handleThumbnailUpload} 417 + style="display: none" 418 + /> 419 + <button 351 420 class="share-btn" 352 421 class:copied 353 422 onclick={handleShare} ··· 486 555 } 487 556 488 557 button.share-btn.copied { 558 + border-color: #10b981; 559 + color: #10b981; 560 + } 561 + 562 + button.thumbnail-btn { 563 + display: flex; 564 + align-items: center; 565 + justify-content: center; 566 + padding: 6px 10px; 567 + } 568 + 569 + button.thumbnail-btn:hover { 570 + border-color: var(--accent, #6366f1); 571 + color: var(--accent, #6366f1); 572 + } 573 + 574 + button.thumbnail-btn.has-thumbnail { 489 575 border-color: #10b981; 490 576 color: #10b981; 491 577 }
+38
src/routes/+page.svelte
··· 89 89 <h3>my decks</h3> 90 90 {#each auth.decks as deck} 91 91 <div class="deck-item"> 92 + <button class="deck-thumb" onclick={() => handleLoadDeck(deck)}> 93 + {#if deck.thumbnailUrl} 94 + <img src={deck.thumbnailUrl} alt="" /> 95 + {:else} 96 + <div class="thumb-placeholder"> 97 + <svg viewBox="0 0 32 32" width="20" height="20"> 98 + <rect x="6" y="4" width="20" height="15" rx="2" fill="currentColor" opacity="0.3"/> 99 + <rect x="6" y="13" width="20" height="15" rx="2" fill="currentColor"/> 100 + </svg> 101 + </div> 102 + {/if} 103 + </button> 92 104 <button class="deck-main" onclick={() => handleLoadDeck(deck)}> 93 105 <span class="deck-title">{deck.name}</span> 94 106 <span class="deck-meta">{deck.slideCount ?? deck.slides.length} slides</span> ··· 267 279 268 280 .deck-item:hover { 269 281 border-color: #555; 282 + } 283 + 284 + .deck-thumb { 285 + width: 64px; 286 + height: 36px; 287 + flex-shrink: 0; 288 + margin-left: 12px; 289 + padding: 0; 290 + background: #0f0f0f; 291 + border: none; 292 + border-radius: 4px; 293 + overflow: hidden; 294 + cursor: pointer; 295 + display: flex; 296 + align-items: center; 297 + justify-content: center; 298 + } 299 + 300 + .deck-thumb img { 301 + width: 100%; 302 + height: 100%; 303 + object-fit: cover; 304 + } 305 + 306 + .thumb-placeholder { 307 + color: #444; 270 308 } 271 309 272 310 .deck-main {
+100
src/routes/@[handle]/+page.server.ts
··· 1 + const DECK_COLLECTION = "tech.waow.slides.deck"; 2 + 3 + async function resolveHandleToDid(handle: string): Promise<string | null> { 4 + // strip @ if present 5 + const cleanHandle = handle.replace(/^@/, ""); 6 + 7 + try { 8 + const res = await fetch( 9 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(cleanHandle)}` 10 + ); 11 + if (res.ok) { 12 + const data = await res.json(); 13 + return data.did || null; 14 + } 15 + } catch {} 16 + return null; 17 + } 18 + 19 + async function resolvePds(did: string): Promise<string> { 20 + try { 21 + if (did.startsWith("did:plc:")) { 22 + const res = await fetch(`https://plc.directory/${did}`); 23 + if (res.ok) { 24 + const doc = await res.json(); 25 + const pds = doc.service?.find( 26 + (s: { id: string; serviceEndpoint?: string }) => s.id === "#atproto_pds" 27 + ); 28 + if (pds?.serviceEndpoint) return pds.serviceEndpoint; 29 + } 30 + } 31 + } catch {} 32 + return "https://bsky.social"; 33 + } 34 + 35 + type DeckRecord = { 36 + name: string; 37 + slides: Array<{ subject: { uri: string; cid: string } }>; 38 + createdAt: string; 39 + updatedAt?: string; 40 + thumbnail?: { 41 + ref: { $link: string }; 42 + mimeType: string; 43 + }; 44 + }; 45 + 46 + async function listDecks(did: string) { 47 + const pdsUrl = (await resolvePds(did)).replace(/\/$/, ""); 48 + 49 + try { 50 + const res = await fetch( 51 + `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${DECK_COLLECTION}&limit=100` 52 + ); 53 + if (!res.ok) return []; 54 + const data = await res.json(); 55 + 56 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 57 + return data.records.map((r: any) => { 58 + const val = r.value as DeckRecord; 59 + const rkey = r.uri.split("/").pop()!; 60 + const thumbnailUrl = val.thumbnail 61 + ? `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(val.thumbnail.ref.$link)}` 62 + : null; 63 + return { 64 + uri: r.uri, 65 + rkey, 66 + name: val.name, 67 + slideCount: val.slides?.length || 0, 68 + createdAt: val.createdAt, 69 + updatedAt: val.updatedAt, 70 + thumbnailUrl, 71 + }; 72 + }); 73 + } catch { 74 + return []; 75 + } 76 + } 77 + 78 + export const load = async ({ params }) => { 79 + const { handle } = params; 80 + const cleanHandle = handle.replace(/^@/, ""); 81 + 82 + const did = await resolveHandleToDid(cleanHandle); 83 + if (!did) { 84 + return { 85 + handle: cleanHandle, 86 + did: null, 87 + decks: [], 88 + error: "user not found", 89 + }; 90 + } 91 + 92 + const decks = await listDecks(did); 93 + 94 + return { 95 + handle: cleanHandle, 96 + did, 97 + decks, 98 + error: null, 99 + }; 100 + };
+212
src/routes/@[handle]/+page.svelte
··· 1 + <script lang="ts"> 2 + let { data } = $props(); 3 + </script> 4 + 5 + <svelte:head> 6 + <title>@{data.handle} - slides</title> 7 + <meta name="description" content={`presentations by @${data.handle}`} /> 8 + <meta property="og:title" content={`@${data.handle}`} /> 9 + <meta property="og:description" content={`presentations by @${data.handle}`} /> 10 + <meta property="og:url" content={`https://slides.waow.tech/@${data.handle}`} /> 11 + </svelte:head> 12 + 13 + <div class="profile"> 14 + <header> 15 + <a href="/" class="back">← slides</a> 16 + </header> 17 + 18 + <div class="profile-header"> 19 + <h1>@{data.handle}</h1> 20 + <a 21 + href="https://bsky.app/profile/{data.handle}" 22 + target="_blank" 23 + rel="noopener" 24 + class="bsky-link" 25 + > 26 + view on bluesky → 27 + </a> 28 + </div> 29 + 30 + {#if data.error} 31 + <div class="error"> 32 + <p>{data.error}</p> 33 + </div> 34 + {:else if data.decks.length === 0} 35 + <div class="empty"> 36 + <p>no presentations yet</p> 37 + </div> 38 + {:else} 39 + <div class="deck-grid"> 40 + {#each data.decks as deck} 41 + <a href="/view/{data.did}/{deck.rkey}" class="deck-card"> 42 + <div class="deck-thumb"> 43 + {#if deck.thumbnailUrl} 44 + <img src={deck.thumbnailUrl} alt="" /> 45 + {:else} 46 + <div class="thumb-placeholder"> 47 + <svg viewBox="0 0 32 32" width="32" height="32"> 48 + <rect x="6" y="4" width="20" height="15" rx="2" fill="currentColor" opacity="0.3"/> 49 + <rect x="6" y="13" width="20" height="15" rx="2" fill="currentColor"/> 50 + </svg> 51 + </div> 52 + {/if} 53 + </div> 54 + <div class="deck-info"> 55 + <span class="deck-title">{deck.name}</span> 56 + <span class="deck-meta">{deck.slideCount} slide{deck.slideCount !== 1 ? 's' : ''}</span> 57 + </div> 58 + </a> 59 + {/each} 60 + </div> 61 + {/if} 62 + 63 + <footer> 64 + <a href="/">create your own</a> 65 + </footer> 66 + </div> 67 + 68 + <style> 69 + .profile { 70 + min-height: 100vh; 71 + background: #0a0a0a; 72 + color: #fff; 73 + padding: 20px; 74 + font-family: system-ui, -apple-system, sans-serif; 75 + } 76 + 77 + header { 78 + margin-bottom: 40px; 79 + } 80 + 81 + .back { 82 + color: #666; 83 + text-decoration: none; 84 + font-size: 14px; 85 + } 86 + 87 + .back:hover { 88 + color: #fff; 89 + } 90 + 91 + .profile-header { 92 + text-align: center; 93 + margin-bottom: 40px; 94 + } 95 + 96 + h1 { 97 + font-size: 32px; 98 + font-weight: 400; 99 + margin: 0 0 8px; 100 + } 101 + 102 + .bsky-link { 103 + color: #666; 104 + font-size: 14px; 105 + text-decoration: none; 106 + } 107 + 108 + .bsky-link:hover { 109 + color: var(--accent, #6366f1); 110 + } 111 + 112 + .error, .empty { 113 + text-align: center; 114 + color: #666; 115 + padding: 60px 20px; 116 + } 117 + 118 + .deck-grid { 119 + display: grid; 120 + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); 121 + gap: 16px; 122 + max-width: 1000px; 123 + margin: 0 auto; 124 + } 125 + 126 + .deck-card { 127 + display: flex; 128 + flex-direction: column; 129 + background: #141414; 130 + border: 1px solid #333; 131 + border-radius: 8px; 132 + overflow: hidden; 133 + text-decoration: none; 134 + color: inherit; 135 + transition: border-color 0.15s ease, transform 0.15s ease; 136 + } 137 + 138 + .deck-card:hover { 139 + border-color: var(--accent, #6366f1); 140 + transform: translateY(-2px); 141 + } 142 + 143 + .deck-thumb { 144 + aspect-ratio: 16 / 9; 145 + background: #0f0f0f; 146 + display: flex; 147 + align-items: center; 148 + justify-content: center; 149 + overflow: hidden; 150 + } 151 + 152 + .deck-thumb img { 153 + width: 100%; 154 + height: 100%; 155 + object-fit: cover; 156 + } 157 + 158 + .thumb-placeholder { 159 + color: #333; 160 + } 161 + 162 + .deck-info { 163 + padding: 12px 16px; 164 + display: flex; 165 + justify-content: space-between; 166 + align-items: center; 167 + } 168 + 169 + .deck-title { 170 + font-weight: 500; 171 + white-space: nowrap; 172 + overflow: hidden; 173 + text-overflow: ellipsis; 174 + } 175 + 176 + .deck-meta { 177 + color: #666; 178 + font-size: 13px; 179 + flex-shrink: 0; 180 + margin-left: 12px; 181 + } 182 + 183 + footer { 184 + text-align: center; 185 + margin-top: 60px; 186 + padding-bottom: 40px; 187 + } 188 + 189 + footer a { 190 + color: var(--accent, #6366f1); 191 + text-decoration: none; 192 + font-size: 14px; 193 + } 194 + 195 + footer a:hover { 196 + text-decoration: underline; 197 + } 198 + 199 + @media (max-width: 600px) { 200 + .profile { 201 + padding: 16px; 202 + } 203 + 204 + h1 { 205 + font-size: 24px; 206 + } 207 + 208 + .deck-grid { 209 + grid-template-columns: 1fr; 210 + } 211 + } 212 + </style>
+17 -372
src/routes/og/[did]/[rkey]/+server.ts
··· 1 - import satori from "satori"; 2 - import { Resvg, initWasm } from "@resvg/resvg-wasm"; 3 1 import type { RequestHandler } from "./$types"; 4 2 5 3 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 4 24 5 async function resolvePds(did: string): Promise<string> { 25 6 try { ··· 35 16 return "https://bsky.social"; 36 17 } 37 18 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 19 type DeckRecord = { 70 20 name: string; 71 21 slides: Array<{ subject: { uri: string; cid: string } }>; 22 + thumbnail?: { 23 + ref: { $link: string }; 24 + mimeType: string; 25 + }; 72 26 }; 73 27 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) { 28 + async function fetchDeck(did: string, rkey: string) { 93 29 const pdsUrl = (await resolvePds(did)).replace(/\/$/, ""); 94 30 95 31 try { ··· 100 36 const data = await res.json(); 101 37 const record = data.value as DeckRecord; 102 38 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 39 return { 109 40 name: record.name, 110 41 slideCount: record.slides?.length || 0, 111 - firstSlide, 42 + thumbnail: record.thumbnail, 112 43 pdsUrl, 113 - did, 114 44 }; 115 45 } catch { 116 46 return null; ··· 121 51 return `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 122 52 } 123 53 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 54 export const GET: RequestHandler = async ({ params }) => { 376 55 const { did, rkey: rawRkey } = params; 377 - const rkey = rawRkey.replace(/\.png$/, ""); 56 + const rkey = rawRkey.replace(/\.(png|jpg|jpeg|webp)$/, ""); 378 57 379 58 try { 380 - await initResvg(); 381 - 382 - const [deck, handle] = await Promise.all([ 383 - fetchDeckWithFirstSlide(did, rkey), 384 - resolveHandle(did), 385 - ]); 59 + const deck = await fetchDeck(did, rkey); 386 60 387 61 if (!deck) { 388 62 return new Response("Not found", { status: 404 }); 389 63 } 390 64 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(); 65 + // if deck has a thumbnail, redirect to the blob URL 66 + if (deck.thumbnail) { 67 + const blobUrl = getBlobUrl(did, deck.thumbnail.ref.$link, deck.pdsUrl); 68 + return Response.redirect(blobUrl, 302); 69 + } 421 70 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 - }); 71 + // no thumbnail set - return 404 so social platforms use fallback 72 + return new Response("No thumbnail", { status: 404 }); 428 73 } catch (error) { 429 - console.error("OG image generation error:", error); 430 - return new Response("Error generating image", { status: 500 }); 74 + console.error("OG image error:", error); 75 + return new Response("Error", { status: 500 }); 431 76 } 432 77 };
+16 -1
src/routes/view/[did]/[rkey]/+page.server.ts
··· 48 48 name: string; 49 49 slides: Array<{ subject: { uri: string; cid: string } }>; 50 50 createdAt: string; 51 + thumbnail?: { 52 + ref: { $link: string }; 53 + mimeType: string; 54 + }; 51 55 }; 52 56 53 57 async function fetchSlide(uri: string, pdsUrl: string): Promise<SlideRecord | null> { ··· 85 89 firstSlide = await fetchSlide(record.slides[0].subject.uri, pdsUrl); 86 90 } 87 91 92 + // resolve thumbnail URL if present 93 + let thumbnailUrl: string | null = null; 94 + if (record.thumbnail) { 95 + const cid = record.thumbnail.ref.$link; 96 + thumbnailUrl = `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 97 + } 98 + 88 99 return { 89 100 name: record.name, 90 101 slideCount: record.slides?.length || 0, 91 102 firstSlide, 92 103 pdsUrl, 104 + thumbnailUrl, 93 105 }; 94 106 } catch { 95 107 return null; ··· 102 114 103 115 const [deck, handle] = await Promise.all([fetchDeck(did, rkey), resolveHandle(did)]); 104 116 117 + // use thumbnail URL directly if available (better for social platforms) 118 + const ogImageUrl = deck?.thumbnailUrl || null; 119 + 105 120 return { 106 121 did, 107 122 rkey, 108 123 slide: isNaN(slide) ? 0 : slide, 109 124 handle, 110 125 deck, 111 - ogImageUrl: `https://slides.waow.tech/og/${did}/${rkey}.png`, 126 + ogImageUrl, 112 127 }; 113 128 };
+189 -11
src/routes/view/[did]/[rkey]/+page.svelte
··· 8 8 let error = $state<string | null>(null); 9 9 let deck = $state<Deck | null>(null); 10 10 let currentSlide = $state(0); 11 + let canvasRef = $state<HTMLDivElement | null>(null); 12 + let canvasScale = $state(1); 11 13 12 14 // Server provides initial data for OG tags 13 15 const deckName = data.deck?.name || "presentation"; 14 16 const slideCount = data.deck?.slideCount || 0; 15 17 const handle = data.handle; 18 + 19 + // touch handling 20 + let touchStartX = 0; 21 + let touchStartY = 0; 22 + const SWIPE_THRESHOLD = 50; 16 23 17 24 onMount(async () => { 18 25 // Fetch full deck client-side for interactivity ··· 25 32 error = "deck not found"; 26 33 loading = false; 27 34 } 35 + 36 + // calculate scale for responsive font sizing 37 + const updateScale = () => { 38 + if (canvasRef) { 39 + // base width is 1000 (since we use val/10 for %) 40 + canvasScale = canvasRef.offsetWidth / 1000; 41 + } 42 + }; 43 + updateScale(); 44 + window.addEventListener("resize", updateScale); 45 + return () => window.removeEventListener("resize", updateScale); 28 46 }); 29 47 30 48 const nextSlide = () => { ··· 64 82 } 65 83 }; 66 84 85 + const handleTouchStart = (e: TouchEvent) => { 86 + touchStartX = e.touches[0].clientX; 87 + touchStartY = e.touches[0].clientY; 88 + }; 89 + 90 + const handleTouchEnd = (e: TouchEvent) => { 91 + const touchEndX = e.changedTouches[0].clientX; 92 + const touchEndY = e.changedTouches[0].clientY; 93 + const deltaX = touchEndX - touchStartX; 94 + const deltaY = touchEndY - touchStartY; 95 + 96 + // only handle horizontal swipes (ignore vertical scrolling attempts) 97 + if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > SWIPE_THRESHOLD) { 98 + if (deltaX < 0) { 99 + nextSlide(); 100 + } else { 101 + prevSlide(); 102 + } 103 + } 104 + }; 105 + 106 + // tap on left/right side of canvas to navigate 107 + const handleCanvasTap = (e: MouseEvent | TouchEvent) => { 108 + if (!canvasRef || !deck) return; 109 + // ignore if clicking a link 110 + if ((e.target as HTMLElement).tagName === "A") return; 111 + 112 + const rect = canvasRef.getBoundingClientRect(); 113 + const clientX = "touches" in e ? e.changedTouches[0].clientX : e.clientX; 114 + const relativeX = (clientX - rect.left) / rect.width; 115 + 116 + if (relativeX < 0.3) { 117 + prevSlide(); 118 + } else if (relativeX > 0.7) { 119 + nextSlide(); 120 + } 121 + }; 122 + 67 123 const scale = (val: number) => `${val / 10}%`; 68 124 125 + // scale font size based on canvas width 126 + const scaledFontSize = (size: number) => Math.round(size * canvasScale); 127 + 69 128 const linkify = (text: string): string => { 70 129 const urlRegex = /(https?:\/\/[^\s<]+)/g; 71 130 return text.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener">$1</a>'); ··· 76 135 77 136 <svelte:head> 78 137 <title>{deckName} - slides</title> 138 + <meta name="description" content={`${slideCount} slide${slideCount !== 1 ? 's' : ''} by @${handle}`} /> 79 139 80 140 <!-- Open Graph --> 81 141 <meta property="og:type" content="website" /> ··· 83 143 <meta property="og:description" content={`${slideCount} slide${slideCount !== 1 ? 's' : ''} by @${handle}`} /> 84 144 <meta property="og:url" content={`https://slides.waow.tech/view/${data.did}/${data.rkey}`} /> 85 145 <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" /> 146 + {#if data.ogImageUrl} 147 + <meta property="og:image" content={data.ogImageUrl} /> 148 + <meta property="og:image:width" content="1200" /> 149 + <meta property="og:image:height" content="630" /> 150 + {/if} 89 151 90 152 <!-- Twitter Card --> 91 - <meta name="twitter:card" content="summary_large_image" /> 153 + <meta name="twitter:card" content={data.ogImageUrl ? "summary_large_image" : "summary"} /> 92 154 <meta name="twitter:title" content={deckName} /> 93 155 <meta name="twitter:description" content={`${slideCount} slide${slideCount !== 1 ? 's' : ''} by @${handle}`} /> 94 - <meta name="twitter:image" content={data.ogImageUrl} /> 156 + {#if data.ogImageUrl} 157 + <meta name="twitter:image" content={data.ogImageUrl} /> 158 + {/if} 95 159 </svelte:head> 96 160 97 161 {#if loading} ··· 106 170 <p>{error}</p> 107 171 </div> 108 172 {:else if deck} 109 - <div class="presentation"> 110 - <div class="canvas"> 173 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 174 + <div 175 + class="presentation" 176 + ontouchstart={handleTouchStart} 177 + ontouchend={handleTouchEnd} 178 + > 179 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 180 + <div 181 + class="canvas" 182 + bind:this={canvasRef} 183 + onclick={handleCanvasTap} 184 + > 111 185 {#if deck.slides[currentSlide]} 112 186 {#each deck.slides[currentSlide].elements as element} 113 187 <div ··· 122 196 top: {scale(element.y)}; 123 197 width: {scale(element.width)}; 124 198 height: {scale(element.height)}; 125 - {element.type === 'text' ? `font-size: ${element.fontSize || 32}px;` : ''} 199 + {element.type === 'text' ? `font-size: ${scaledFontSize(element.fontSize || 32)}px;` : ''} 126 200 {element.color ? `color: ${element.color}; background-color: ${element.type === 'shape' ? element.color : 'transparent'};` : ''} 127 201 " 128 202 > ··· 134 208 </div> 135 209 {/each} 136 210 {/if} 211 + 212 + <!-- touch hint zones (visual feedback on mobile) --> 213 + <div class="touch-zone left"></div> 214 + <div class="touch-zone right"></div> 137 215 </div> 138 216 139 217 <div class="controls"> ··· 141 219 <button onclick={prevSlide} disabled={currentSlide === 0}>←</button> 142 220 <span class="slide-counter">{currentSlide + 1} / {deck.slides.length}</span> 143 221 <button onclick={nextSlide} disabled={currentSlide >= deck.slides.length - 1}>→</button> 144 - <span class="author">by <a href="https://bsky.app/profile/{handle}" target="_blank" rel="noopener">@{handle}</a></span> 222 + <span class="author">by <a href="/@{handle}">@{handle}</a></span> 145 223 </div> 146 224 </div> 147 225 {/if} ··· 154 232 color: #fff; 155 233 font-family: system-ui, -apple-system, sans-serif; 156 234 overflow: hidden; 235 + touch-action: manipulation; 157 236 } 158 237 159 238 .loading, .error { ··· 176 255 177 256 .presentation { 178 257 height: 100vh; 258 + height: 100dvh; /* dynamic viewport height for mobile */ 179 259 display: flex; 180 260 flex-direction: column; 261 + user-select: none; 262 + -webkit-user-select: none; 181 263 } 182 264 183 265 .canvas { ··· 186 268 background: #0f0f0f; 187 269 aspect-ratio: 16 / 9; 188 270 max-height: calc(100vh - 60px); 271 + max-height: calc(100dvh - 60px); 189 272 margin: auto; 190 273 width: 100%; 191 274 max-width: calc((100vh - 60px) * 16 / 9); 275 + max-width: calc((100dvh - 60px) * 16 / 9); 276 + cursor: pointer; 277 + } 278 + 279 + /* touch zone hints - show on first tap */ 280 + .touch-zone { 281 + position: absolute; 282 + top: 0; 283 + bottom: 0; 284 + width: 30%; 285 + pointer-events: none; 286 + opacity: 0; 287 + transition: opacity 0.2s ease; 288 + } 289 + 290 + .touch-zone.left { 291 + left: 0; 292 + background: linear-gradient(to right, rgba(255,255,255,0.03), transparent); 293 + } 294 + 295 + .touch-zone.right { 296 + right: 0; 297 + background: linear-gradient(to left, rgba(255,255,255,0.03), transparent); 298 + } 299 + 300 + .canvas:active .touch-zone.left, 301 + .canvas:active .touch-zone.right { 302 + opacity: 1; 192 303 } 193 304 194 305 .element { ··· 205 316 206 317 .element.text .text-content { 207 318 padding: 12px; 319 + word-break: break-word; 320 + overflow-wrap: break-word; 208 321 } 209 322 210 323 .element.text .text-content :global(a) { 211 - color: #6366f1; 324 + color: inherit; 212 325 text-decoration: underline; 326 + text-underline-offset: 2px; 213 327 cursor: pointer; 214 328 } 215 329 216 330 .element.text .text-content :global(a:hover) { 217 - opacity: 0.8; 331 + text-decoration-thickness: 2px; 218 332 } 219 333 220 334 .element.shape { ··· 234 348 width: 100%; 235 349 height: 100%; 236 350 object-fit: contain; 351 + pointer-events: none; 237 352 } 238 353 239 354 .controls { ··· 243 358 gap: 16px; 244 359 padding: 12px; 245 360 background: rgba(0, 0, 0, 0.9); 361 + flex-shrink: 0; 246 362 } 247 363 248 364 .controls button { ··· 252 368 color: #fff; 253 369 cursor: pointer; 254 370 border-radius: 4px; 371 + min-width: 44px; 372 + min-height: 44px; 373 + font-size: 18px; 255 374 } 256 375 257 376 .controls button:hover:not(:disabled) { ··· 300 419 301 420 .create-link a:hover { 302 421 text-decoration: underline; 422 + } 423 + 424 + /* mobile styles */ 425 + @media (max-width: 600px) { 426 + .controls { 427 + padding: 8px; 428 + gap: 8px; 429 + } 430 + 431 + .controls button { 432 + padding: 10px 14px; 433 + } 434 + 435 + .slide-counter { 436 + font-size: 16px; 437 + min-width: 50px; 438 + } 439 + 440 + /* hide "create your own" and show author below on small screens */ 441 + .create-link { 442 + display: none; 443 + } 444 + 445 + .author { 446 + position: static; 447 + order: 10; 448 + margin-left: auto; 449 + } 450 + } 451 + 452 + /* very small screens - stack controls */ 453 + @media (max-width: 360px) { 454 + .controls { 455 + flex-wrap: wrap; 456 + gap: 6px; 457 + } 458 + 459 + .author { 460 + width: 100%; 461 + text-align: center; 462 + margin: 4px 0 0; 463 + } 464 + } 465 + 466 + /* landscape mobile - reduce control bar height */ 467 + @media (max-height: 500px) { 468 + .controls { 469 + padding: 6px 12px; 470 + } 471 + 472 + .controls button { 473 + padding: 6px 12px; 474 + min-height: 36px; 475 + } 476 + 477 + .canvas { 478 + max-height: calc(100dvh - 48px); 479 + max-width: calc((100dvh - 48px) * 16 / 9); 480 + } 303 481 } 304 482 </style>