powerpointproto slides.waow.tech
slides

add public share functionality

- public view route at /view/[did]/[rkey] (no auth required)
- share button on homepage copies link to clipboard
- fix slide count showing 0 (use slideCount from record)
- fix links not clickable in presentation mode (enable pointer-events)
- resolve PDS URL from DID document for public blob access

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

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

+404 -7
+26
src/lib/api/client.ts
··· 83 83 } catch { /* ignore */ } 84 84 return did; 85 85 }; 86 + 87 + // PDS URL resolution from DID document 88 + const pdsCache = new Map<string, string>(); 89 + 90 + export const resolvePdsUrl = async (did: string): Promise<string> => { 91 + if (pdsCache.has(did)) return pdsCache.get(did)!; 92 + try { 93 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 94 + const doc = await didDocumentResolver.resolve(did as any); 95 + const pds = doc.service?.find(s => s.id === "#atproto_pds"); 96 + if (pds && typeof pds.serviceEndpoint === "string") { 97 + pdsCache.set(did, pds.serviceEndpoint); 98 + return pds.serviceEndpoint; 99 + } 100 + } catch { /* ignore */ } 101 + return "https://bsky.social"; 102 + }; 103 + 104 + // public API fetch (no auth required) 105 + export const publicFetch = async (pdsUrl: string, method: string, params: Record<string, string>) => { 106 + const url = new URL(`/xrpc/${method}`, pdsUrl); 107 + Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); 108 + const res = await fetch(url.toString()); 109 + if (!res.ok) return null; 110 + return res.json(); 111 + };
+37 -4
src/lib/api/deck.ts
··· 1 - import { currentDid, getRpc } from "./client"; 2 - import { createSlide, deleteSlide, getSlide, updateSlide } from "./slide"; 1 + import { currentDid, getRpc, publicFetch, resolvePdsUrl } from "./client"; 2 + import { createSlide, deleteSlide, getPublicSlide, getSlide, updateSlide } from "./slide"; 3 3 import { DECK, type Deck, type DeckRecord, type Slide, type SlideRef } from "./types"; 4 4 5 5 const rkeyFromUri = (uri: string): string => uri.split("/").pop()!; ··· 175 175 return res.data.records.map((r: any) => { 176 176 const val = r.value as DeckRecord; 177 177 const rkey = r.uri.split("/").pop()!; 178 - // return shallow deck (slides not resolved yet) 178 + // return shallow deck with slide count (slides not resolved yet) 179 179 return { 180 180 uri: r.uri, 181 181 repo: currentDid!, 182 182 rkey, 183 183 name: val.name, 184 - slides: [], // caller should use getDeck for full resolution 184 + slides: [], 185 + slideCount: val.slides.length, // for display before full resolution 185 186 createdAt: val.createdAt, 186 187 updatedAt: val.updatedAt, 187 188 }; 188 189 }); 189 190 }; 191 + 192 + // public deck fetch (no auth required) 193 + export const getPublicDeck = async (did: string, rkey: string): Promise<Deck | null> => { 194 + const pdsUrl = await resolvePdsUrl(did); 195 + 196 + const data = await publicFetch(pdsUrl, "com.atproto.repo.getRecord", { 197 + repo: did, 198 + collection: DECK, 199 + rkey, 200 + }); 201 + 202 + if (!data) return null; 203 + 204 + const record = data.value as DeckRecord; 205 + 206 + // resolve all slide refs 207 + const slides: Slide[] = []; 208 + for (const ref of record.slides) { 209 + const slide = await getPublicSlide(ref.subject.uri, pdsUrl); 210 + if (slide) slides.push(slide); 211 + } 212 + 213 + return { 214 + uri: data.uri, 215 + repo: did, 216 + rkey, 217 + name: record.name, 218 + slides, 219 + createdAt: record.createdAt, 220 + updatedAt: record.updatedAt, 221 + }; 222 + };
+4
src/lib/api/index.ts
··· 21 21 getCurrentDid, 22 22 getPdsUrl, 23 23 handleResolver, 24 + publicFetch, 24 25 resolveHandle, 26 + resolvePdsUrl, 25 27 setAgent, 26 28 setCurrentDid, 27 29 } from "./client"; ··· 50 52 export { 51 53 createSlide, 52 54 deleteSlide, 55 + getPublicSlide, 53 56 getSlide, 54 57 updateSlide, 55 58 } from "./slide"; ··· 59 62 createDeck, 60 63 deleteDeck, 61 64 getDeck, 65 + getPublicDeck, 62 66 listMyDecks, 63 67 updateDeck, 64 68 } from "./deck";
+25 -1
src/lib/api/slide.ts
··· 1 - import { currentDid, getRpc, getPdsUrl } from "./client"; 1 + import { currentDid, getRpc, getPdsUrl, publicFetch, resolvePdsUrl } from "./client"; 2 2 import { prepareSlideForSave, resolveSlideBlobs } from "./blob"; 3 3 import { SLIDE, type Slide, type SlideRecord, type StrongRef } from "./types"; 4 4 ··· 96 96 pdsUrl 97 97 ); 98 98 }; 99 + 100 + // public slide fetch (no auth required) 101 + export const getPublicSlide = async (uri: string, pdsUrl: string): Promise<Slide | null> => { 102 + // parse uri: at://did/collection/rkey 103 + const parts = uri.split("/"); 104 + const rkey = parts.pop()!; 105 + const collection = parts.pop()!; 106 + const did = parts.slice(2).join("/"); 107 + 108 + const data = await publicFetch(pdsUrl, "com.atproto.repo.getRecord", { 109 + repo: did, 110 + collection, 111 + rkey, 112 + }); 113 + 114 + if (!data) return null; 115 + 116 + const record = data.value as SlideRecord; 117 + return resolveSlideBlobs( 118 + { ...record, uri, cid: data.cid, rkey }, 119 + did, 120 + pdsUrl 121 + ); 122 + };
+1
src/lib/api/types.ts
··· 64 64 rkey?: string; 65 65 name: string; 66 66 slides: Slide[]; 67 + slideCount?: number; // for shallow decks before slides are resolved 67 68 createdAt: string; 68 69 updatedAt?: string; 69 70 };
+4
src/lib/components/SlideCanvas.svelte
··· 390 390 padding: 12px; 391 391 } 392 392 393 + .canvas.presenting .element.text .text-content { 394 + pointer-events: auto; 395 + } 396 + 393 397 .element.shape { 394 398 border-radius: 8px; 395 399 }
+41 -2
src/routes/+page.svelte
··· 26 26 27 27 let deleting = $state<string | null>(null); 28 28 let confirmDelete = $state<string | null>(null); 29 + let copied = $state<string | null>(null); 30 + 31 + const handleShare = async (e: MouseEvent, deck: Deck) => { 32 + e.stopPropagation(); 33 + if (!auth.did || !deck.rkey) return; 34 + 35 + const url = `${window.location.origin}/view/${auth.did}/${deck.rkey}`; 36 + await navigator.clipboard.writeText(url); 37 + copied = deck.rkey; 38 + setTimeout(() => { copied = null; }, 2000); 39 + }; 29 40 30 41 const handleDelete = async (rkey: string) => { 31 42 if (confirmDelete !== rkey) { ··· 80 91 <div class="deck-item"> 81 92 <button class="deck-main" onclick={() => handleLoadDeck(deck)}> 82 93 <span class="deck-title">{deck.name}</span> 83 - <span class="deck-meta">{deck.slides.length} slides</span> 94 + <span class="deck-meta">{deck.slideCount ?? deck.slides.length} slides</span> 84 95 </button> 85 96 {#if confirmDelete === deck.rkey} 86 97 <div class="delete-confirm"> ··· 94 105 <button class="delete-no" onclick={cancelDelete}>cancel</button> 95 106 </div> 96 107 {:else} 108 + <button 109 + class="share-btn" 110 + class:copied={copied === deck.rkey} 111 + onclick={(e) => handleShare(e, deck)} 112 + title={copied === deck.rkey ? "Copied!" : "Copy share link"} 113 + > 114 + {#if copied === deck.rkey} 115 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 116 + <path d="M20 6L9 17l-5-5"/> 117 + </svg> 118 + {:else} 119 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 120 + <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/> 121 + </svg> 122 + {/if} 123 + </button> 97 124 <button 98 125 class="delete-btn" 99 126 onclick={(e) => { e.stopPropagation(); handleDelete(deck.rkey!); }} ··· 264 291 font-size: 13px; 265 292 } 266 293 267 - .delete-btn { 294 + .share-btn, .delete-btn { 268 295 padding: 8px; 269 296 background: transparent; 270 297 border: none; 271 298 color: #666; 272 299 cursor: pointer; 273 300 border-radius: 4px; 301 + } 302 + 303 + .delete-btn { 274 304 margin-right: 8px; 305 + } 306 + 307 + .share-btn:hover { 308 + color: var(--accent, #6366f1); 309 + background: rgba(99, 102, 241, 0.1); 310 + } 311 + 312 + .share-btn.copied { 313 + color: #10b981; 275 314 } 276 315 277 316 .delete-btn:hover {
+258
src/routes/view/[did]/[rkey]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from "svelte"; 3 + import { getPublicDeck, resolveHandle, resolveDeckBlobs } from "$lib/api"; 4 + import type { Deck } from "$lib/api"; 5 + 6 + let { data } = $props(); 7 + let loading = $state(true); 8 + let error = $state<string | null>(null); 9 + let deck = $state<Deck | null>(null); 10 + let currentSlide = $state(0); 11 + let handle = $state<string | null>(null); 12 + 13 + onMount(async () => { 14 + const result = await getPublicDeck(data.did, data.rkey); 15 + if (result) { 16 + deck = resolveDeckBlobs(result, data.did); 17 + currentSlide = Math.min(data.slide, deck.slides.length - 1); 18 + handle = await resolveHandle(data.did); 19 + loading = false; 20 + } else { 21 + error = "deck not found"; 22 + loading = false; 23 + } 24 + }); 25 + 26 + const nextSlide = () => { 27 + if (deck && currentSlide < deck.slides.length - 1) { 28 + currentSlide++; 29 + updateUrl(); 30 + } 31 + }; 32 + 33 + const prevSlide = () => { 34 + if (currentSlide > 0) { 35 + currentSlide--; 36 + updateUrl(); 37 + } 38 + }; 39 + 40 + const updateUrl = () => { 41 + const url = new URL(window.location.href); 42 + if (currentSlide > 0) { 43 + url.searchParams.set("slide", String(currentSlide)); 44 + } else { 45 + url.searchParams.delete("slide"); 46 + } 47 + history.replaceState({}, "", url.toString()); 48 + }; 49 + 50 + const handleKeydown = (e: KeyboardEvent) => { 51 + switch (e.key) { 52 + case "ArrowRight": 53 + case " ": 54 + case "Enter": 55 + nextSlide(); 56 + break; 57 + case "ArrowLeft": 58 + prevSlide(); 59 + break; 60 + } 61 + }; 62 + 63 + const scale = (val: number) => `${val / 10}%`; 64 + 65 + const linkify = (text: string): string => { 66 + const urlRegex = /(https?:\/\/[^\s<]+)/g; 67 + return text.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener">$1</a>'); 68 + }; 69 + </script> 70 + 71 + <svelte:window onkeydown={handleKeydown} /> 72 + 73 + <svelte:head> 74 + <title>{deck?.name || "presentation"} - slides</title> 75 + </svelte:head> 76 + 77 + {#if loading} 78 + <div class="loading"> 79 + <svg class="loading-icon" viewBox="0 0 32 32" width="48" height="48"> 80 + <rect x="6" y="4" width="20" height="15" rx="2" fill="currentColor" opacity="0.3"/> 81 + <rect x="6" y="13" width="20" height="15" rx="2" fill="currentColor"/> 82 + </svg> 83 + </div> 84 + {:else if error} 85 + <div class="error"> 86 + <p>{error}</p> 87 + </div> 88 + {:else if deck} 89 + <div class="presentation"> 90 + <div class="canvas"> 91 + {#if deck.slides[currentSlide]} 92 + {#each deck.slides[currentSlide].elements as element} 93 + <div 94 + class="element" 95 + class:text={element.type === "text"} 96 + class:shape={element.type === "shape"} 97 + class:shape-ellipse={element.type === "shape" && element.shapeType === "ellipse"} 98 + class:shape-triangle={element.type === "shape" && element.shapeType === "triangle"} 99 + class:image={element.type === "image"} 100 + style=" 101 + left: {scale(element.x)}; 102 + top: {scale(element.y)}; 103 + width: {scale(element.width)}; 104 + height: {scale(element.height)}; 105 + {element.type === 'text' ? `font-size: ${element.fontSize || 32}px;` : ''} 106 + {element.color ? `color: ${element.color}; background-color: ${element.type === 'shape' ? element.color : 'transparent'};` : ''} 107 + " 108 + > 109 + {#if element.type === "text"} 110 + <span class="text-content" style={element.fontFamily ? `font-family: ${element.fontFamily};` : ''}>{@html linkify(element.content || "")}</span> 111 + {:else if element.type === "image" && element.content} 112 + <img src={element.content} alt="" draggable="false" /> 113 + {/if} 114 + </div> 115 + {/each} 116 + {/if} 117 + </div> 118 + 119 + <div class="controls"> 120 + <button onclick={prevSlide} disabled={currentSlide === 0}>←</button> 121 + <span class="slide-counter">{currentSlide + 1} / {deck.slides.length}</span> 122 + <button onclick={nextSlide} disabled={currentSlide >= deck.slides.length - 1}>→</button> 123 + <span class="author">by @{handle || data.did}</span> 124 + </div> 125 + </div> 126 + {/if} 127 + 128 + <style> 129 + :global(body) { 130 + margin: 0; 131 + padding: 0; 132 + background: #000; 133 + color: #fff; 134 + font-family: system-ui, -apple-system, sans-serif; 135 + overflow: hidden; 136 + } 137 + 138 + .loading, .error { 139 + display: flex; 140 + align-items: center; 141 + justify-content: center; 142 + height: 100vh; 143 + color: #666; 144 + } 145 + 146 + .loading-icon { 147 + color: #6366f1; 148 + animation: pulse 1.5s ease-in-out infinite; 149 + } 150 + 151 + @keyframes pulse { 152 + 0%, 100% { opacity: 0.4; transform: scale(1); } 153 + 50% { opacity: 1; transform: scale(1.05); } 154 + } 155 + 156 + .presentation { 157 + height: 100vh; 158 + display: flex; 159 + flex-direction: column; 160 + } 161 + 162 + .canvas { 163 + flex: 1; 164 + position: relative; 165 + background: #0f0f0f; 166 + aspect-ratio: 16 / 9; 167 + max-height: calc(100vh - 60px); 168 + margin: auto; 169 + width: 100%; 170 + max-width: calc((100vh - 60px) * 16 / 9); 171 + } 172 + 173 + .element { 174 + position: absolute; 175 + display: flex; 176 + align-items: center; 177 + justify-content: center; 178 + box-sizing: border-box; 179 + } 180 + 181 + .element.text { 182 + text-align: center; 183 + } 184 + 185 + .element.text .text-content { 186 + padding: 12px; 187 + } 188 + 189 + .element.text .text-content :global(a) { 190 + color: #6366f1; 191 + text-decoration: underline; 192 + cursor: pointer; 193 + } 194 + 195 + .element.text .text-content :global(a:hover) { 196 + opacity: 0.8; 197 + } 198 + 199 + .element.shape { 200 + border-radius: 8px; 201 + } 202 + 203 + .element.shape-ellipse { 204 + border-radius: 50%; 205 + } 206 + 207 + .element.shape-triangle { 208 + border-radius: 0; 209 + clip-path: polygon(50% 0%, 100% 100%, 0% 100%); 210 + } 211 + 212 + .element.image img { 213 + width: 100%; 214 + height: 100%; 215 + object-fit: contain; 216 + } 217 + 218 + .controls { 219 + display: flex; 220 + align-items: center; 221 + justify-content: center; 222 + gap: 16px; 223 + padding: 12px; 224 + background: rgba(0, 0, 0, 0.9); 225 + } 226 + 227 + .controls button { 228 + padding: 8px 16px; 229 + background: #333; 230 + border: 1px solid #555; 231 + color: #fff; 232 + cursor: pointer; 233 + border-radius: 4px; 234 + } 235 + 236 + .controls button:hover:not(:disabled) { 237 + background: #444; 238 + } 239 + 240 + .controls button:disabled { 241 + opacity: 0.5; 242 + cursor: not-allowed; 243 + } 244 + 245 + .slide-counter { 246 + font-size: 18px; 247 + font-weight: bold; 248 + min-width: 60px; 249 + text-align: center; 250 + } 251 + 252 + .author { 253 + position: absolute; 254 + right: 16px; 255 + font-size: 13px; 256 + color: #666; 257 + } 258 + </style>
+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 + };