powerpointproto slides.waow.tech
slides
at main 745 lines 19 kB view raw
1<script lang="ts"> 2 import { onMount } from "svelte"; 3 import { goto } from "$app/navigation"; 4 import { editorState, newDeck, loadDeck, startPresenting } from "$lib/state.svelte"; 5 import { auth, initAuth, doDeleteDeck } from "$lib/auth.svelte"; 6 import { getDeck, getBlobUrl, resolvePdsUrl, type Deck } from "$lib/api"; 7 import Toolbar from "$lib/components/Toolbar.svelte"; 8 import SlideList from "$lib/components/SlideList.svelte"; 9 import SlideCanvas from "$lib/components/SlideCanvas.svelte"; 10 import PresenterView from "$lib/components/PresenterView.svelte"; 11 12 // Public gallery types 13 type PublicDeck = { 14 did: string; 15 rkey: string; 16 name: string; 17 slideCount: number; 18 thumbnailUrl?: string; 19 handle?: string; 20 }; 21 22 let publicDecks = $state<PublicDeck[]>([]); 23 let loadingPublic = $state(false); 24 25 const fetchPublicDecks = async () => { 26 loadingPublic = true; 27 try { 28 const res = await fetch("https://ufos-api.microcosm.blue/records?collection=tech.waow.slides.deck&limit=8"); 29 if (!res.ok) return; 30 const records = await res.json(); 31 32 // Get unique DIDs and resolve their PDS URLs + handles in parallel 33 type RecordType = { 34 did: string; 35 rkey: string; 36 record: { 37 name: string; 38 slides: unknown[]; 39 thumbnail?: { ref: { $link: string }; mimeType: string }; 40 }; 41 }; 42 const dids = [...new Set(records.map((r: RecordType) => r.did))]; 43 const pdsMap = new Map<string, string>(); 44 const handleMap = new Map<string, string>(); 45 46 await Promise.all([ 47 // Resolve PDS URLs 48 ...dids.map(async (did) => { 49 try { 50 const pdsUrl = await resolvePdsUrl(did); 51 pdsMap.set(did, pdsUrl); 52 } catch {} 53 }), 54 // Resolve handles via getProfiles 55 (async () => { 56 if (dids.length > 0) { 57 try { 58 const params = dids.map((d: string) => `actors=${d}`).join("&"); 59 const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?${params}`); 60 if (res.ok) { 61 const data = await res.json(); 62 for (const profile of data.profiles || []) { 63 handleMap.set(profile.did, profile.handle); 64 } 65 } 66 } catch {} 67 } 68 })(), 69 ]); 70 71 // Map to our format with resolved PDS URLs 72 const decks: PublicDeck[] = records.map((r: RecordType) => ({ 73 did: r.did, 74 rkey: r.rkey, 75 name: r.record.name || "untitled", 76 slideCount: r.record.slides?.length || 0, 77 thumbnailUrl: r.record.thumbnail 78 ? getBlobUrl(r.did, r.record.thumbnail.ref.$link, pdsMap.get(r.did)) 79 : undefined, 80 handle: handleMap.get(r.did), 81 })); 82 83 publicDecks = decks; 84 } catch (e) { 85 console.error("failed to fetch public decks:", e); 86 } finally { 87 loadingPublic = false; 88 } 89 }; 90 91 onMount(() => { 92 initAuth(); 93 }); 94 95 // Reactively fetch public decks when logged out 96 $effect(() => { 97 if (auth.ready && !auth.loggedIn && publicDecks.length === 0) { 98 fetchPublicDecks(); 99 } 100 }); 101 102 const handleNewDeck = () => { 103 newDeck(); 104 }; 105 106 const handleLoadDeck = (deck: Deck) => { 107 // Navigate to the deck's URL instead of loading in place 108 if (deck.rkey) { 109 goto(`/deck/${deck.rkey}`); 110 } 111 }; 112 113 let deleting = $state<string | null>(null); 114 let confirmDelete = $state<string | null>(null); 115 let copied = $state<string | null>(null); 116 117 const handleShare = async (e: MouseEvent, deck: Deck) => { 118 e.stopPropagation(); 119 if (!auth.did || !deck.rkey) return; 120 121 const url = `${window.location.origin}/view/${auth.did}/${deck.rkey}`; 122 await navigator.clipboard.writeText(url); 123 copied = deck.rkey; 124 setTimeout(() => { copied = null; }, 2000); 125 }; 126 127 const handleDelete = async (rkey: string) => { 128 if (confirmDelete !== rkey) { 129 confirmDelete = rkey; 130 return; 131 } 132 deleting = rkey; 133 try { 134 await doDeleteDeck(rkey); 135 } finally { 136 deleting = null; 137 confirmDelete = null; 138 } 139 }; 140 141 const cancelDelete = (e: MouseEvent) => { 142 e.stopPropagation(); 143 confirmDelete = null; 144 }; 145 146 let presenting = $state<string | null>(null); 147 148 const handlePresent = async (e: MouseEvent, deck: Deck) => { 149 e.stopPropagation(); 150 if (!deck.rkey || !deck.repo) return; 151 152 presenting = deck.rkey; 153 try { 154 const fullDeck = await getDeck(deck.repo, deck.rkey); 155 if (fullDeck) { 156 loadDeck(fullDeck); 157 startPresenting(); 158 } 159 } finally { 160 presenting = null; 161 } 162 }; 163</script> 164 165<svelte:head> 166 <title>slides</title> 167 <meta name="description" content="create and present slides on atproto" /> 168 <meta property="og:title" content="slides" /> 169 <meta property="og:description" content="create and present slides on atproto" /> 170 <meta property="og:type" content="website" /> 171 <meta property="og:url" content="https://slides.waow.tech" /> 172 <meta name="twitter:card" content="summary" /> 173 <meta name="twitter:title" content="slides" /> 174 <meta name="twitter:description" content="create and present slides on atproto" /> 175</svelte:head> 176 177{#if !auth.ready} 178 <div class="loading"> 179 <svg class="loading-icon" viewBox="0 0 32 32" width="48" height="48"> 180 <rect x="6" y="4" width="20" height="15" rx="2" fill="currentColor" opacity="0.3"/> 181 <rect x="6" y="13" width="20" height="15" rx="2" fill="currentColor"/> 182 </svg> 183 </div> 184{:else if editorState.isPresenting} 185 <div 186 class="presenter-canvas" 187 style="--panel-height: {editorState.presenterPanelHeight}px;" 188 > 189 <SlideCanvas /> 190 <PresenterView /> 191 </div> 192{:else if !editorState.deck} 193 <div class="home"> 194 <h1>slides</h1> 195 <p class="subtitle">create and present from anywhere</p> 196 197 {#if auth.loggedIn} 198 <div class="actions"> 199 <button onclick={handleNewDeck}>+ new deck</button> 200 </div> 201 202 {#if auth.decks.length > 0} 203 <div class="deck-list"> 204 <h3>my decks</h3> 205 {#each auth.decks as deck} 206 <div class="deck-item"> 207 <button class="deck-thumb" onclick={() => handleLoadDeck(deck)}> 208 {#if deck.thumbnailUrl} 209 <img src={deck.thumbnailUrl} alt="" /> 210 {:else} 211 <div class="thumb-placeholder"> 212 <svg viewBox="0 0 32 32" width="20" height="20"> 213 <rect x="6" y="4" width="20" height="15" rx="2" fill="currentColor" opacity="0.3"/> 214 <rect x="6" y="13" width="20" height="15" rx="2" fill="currentColor"/> 215 </svg> 216 </div> 217 {/if} 218 </button> 219 <button class="deck-main" onclick={() => handleLoadDeck(deck)}> 220 <span class="deck-title">{deck.name}</span> 221 <span class="deck-meta">{deck.slideCount ?? deck.slides.length} slides</span> 222 </button> 223 {#if confirmDelete === deck.rkey} 224 <div class="delete-confirm"> 225 <button 226 class="delete-yes" 227 onclick={() => handleDelete(deck.rkey!)} 228 disabled={deleting === deck.rkey} 229 > 230 {deleting === deck.rkey ? "..." : "delete"} 231 </button> 232 <button class="delete-no" onclick={cancelDelete}>cancel</button> 233 </div> 234 {:else} 235 <button 236 class="present-btn" 237 onclick={(e) => handlePresent(e, deck)} 238 disabled={presenting === deck.rkey} 239 title="Present" 240 > 241 {#if presenting === deck.rkey} 242 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinning"> 243 <circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="12"/> 244 </svg> 245 {:else} 246 <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> 247 <path d="M8 5v14l11-7z"/> 248 </svg> 249 {/if} 250 </button> 251 <button 252 class="share-btn" 253 class:copied={copied === deck.rkey} 254 onclick={(e) => handleShare(e, deck)} 255 title={copied === deck.rkey ? "Copied!" : "Copy share link"} 256 > 257 {#if copied === deck.rkey} 258 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 259 <path d="M20 6L9 17l-5-5"/> 260 </svg> 261 {:else} 262 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 263 <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/> 264 </svg> 265 {/if} 266 </button> 267 <button 268 class="delete-btn" 269 onclick={(e) => { e.stopPropagation(); handleDelete(deck.rkey!); }} 270 title="Delete deck" 271 > 272 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 273 <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/> 274 </svg> 275 </button> 276 {/if} 277 </div> 278 {/each} 279 </div> 280 {/if} 281 {:else} 282 {#if loadingPublic} 283 <div class="public-loading"> 284 <svg class="loading-icon small" viewBox="0 0 32 32" width="24" height="24"> 285 <rect x="6" y="4" width="20" height="15" rx="2" fill="currentColor" opacity="0.3"/> 286 <rect x="6" y="13" width="20" height="15" rx="2" fill="currentColor"/> 287 </svg> 288 </div> 289 {:else if publicDecks.length > 0} 290 <div class="public-gallery"> 291 <div class="gallery-grid"> 292 {#each publicDecks as deck (deck.rkey)} 293 <a href="/view/{deck.did}/{deck.rkey}" class="gallery-card"> 294 <div class="gallery-thumb"> 295 {#if deck.thumbnailUrl} 296 <img src={deck.thumbnailUrl} alt="" /> 297 {:else} 298 <div class="thumb-placeholder"> 299 <svg viewBox="0 0 32 32" width="24" height="24"> 300 <rect x="6" y="4" width="20" height="15" rx="2" fill="currentColor" opacity="0.3"/> 301 <rect x="6" y="13" width="20" height="15" rx="2" fill="currentColor"/> 302 </svg> 303 </div> 304 {/if} 305 </div> 306 <div class="gallery-info"> 307 <span class="gallery-title">{deck.name}</span> 308 <span class="gallery-meta"> 309 {deck.slideCount} slide{deck.slideCount !== 1 ? "s" : ""} 310 {#if deck.handle} 311 · @{deck.handle} 312 {/if} 313 </span> 314 </div> 315 </a> 316 {/each} 317 </div> 318 <p class="login-cta">login to create your own →</p> 319 </div> 320 {:else} 321 <p class="login-cta">login to create presentations</p> 322 {/if} 323 {/if} 324 325 <Toolbar /> 326 </div> 327{:else} 328 <div class="editor"> 329 <Toolbar /> 330 <div class="workspace"> 331 <SlideList /> 332 <div class="canvas-area"> 333 <SlideCanvas /> 334 </div> 335 </div> 336 </div> 337{/if} 338 339<style> 340 :global(:root) { 341 --accent: #6366f1; 342 --font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 343 } 344 345 :global(body) { 346 margin: 0; 347 padding: 0; 348 background: #0a0a0a; 349 color: #fff; 350 font-family: var(--font); 351 } 352 353 :global(*) { 354 box-sizing: border-box; 355 } 356 357 :global(button, input, select, textarea) { 358 font-family: inherit; 359 } 360 361 .loading { 362 display: flex; 363 align-items: center; 364 justify-content: center; 365 height: 100vh; 366 background: #0a0a0a; 367 animation: fadeIn 0.2s ease; 368 } 369 370 .loading-icon { 371 color: var(--accent, #6366f1); 372 animation: pulse 1.5s ease-in-out infinite; 373 } 374 375 @keyframes fadeIn { 376 from { opacity: 0; } 377 to { opacity: 1; } 378 } 379 380 @keyframes pulse { 381 0%, 100% { opacity: 0.4; transform: scale(1); } 382 50% { opacity: 1; transform: scale(1.05); } 383 } 384 385 .home { 386 display: flex; 387 flex-direction: column; 388 align-items: center; 389 padding: 80px 20px 40px; 390 min-height: 100vh; 391 } 392 393 .home h1 { 394 font-size: 42px; 395 margin: 0; 396 font-weight: 300; 397 letter-spacing: -1px; 398 } 399 400 .subtitle { 401 color: #555; 402 margin: 4px 0 0; 403 font-size: 15px; 404 } 405 406 .login-cta { 407 color: #666; 408 font-size: 14px; 409 margin-top: 24px; 410 text-align: center; 411 } 412 413 .actions { 414 margin-top: 32px; 415 margin-bottom: 40px; 416 } 417 418 .actions button { 419 padding: 12px 24px; 420 background: var(--accent); 421 border: none; 422 color: #fff; 423 font-size: 16px; 424 cursor: pointer; 425 border-radius: 6px; 426 transition: opacity 0.15s ease; 427 } 428 429 .actions button:hover { 430 opacity: 0.9; 431 } 432 433 .deck-list { 434 width: 100%; 435 max-width: 400px; 436 } 437 438 .deck-list h3 { 439 font-size: 14px; 440 color: #666; 441 text-transform: uppercase; 442 margin-bottom: 12px; 443 } 444 445 .deck-item { 446 display: flex; 447 align-items: center; 448 gap: 8px; 449 background: #1a1a1a; 450 border: 1px solid #333; 451 border-radius: 6px; 452 margin-bottom: 8px; 453 } 454 455 .deck-item:hover { 456 border-color: #555; 457 } 458 459 .deck-thumb { 460 width: 64px; 461 height: 36px; 462 flex-shrink: 0; 463 margin-left: 12px; 464 padding: 0; 465 background: #0f0f0f; 466 border: none; 467 border-radius: 4px; 468 overflow: hidden; 469 cursor: pointer; 470 display: flex; 471 align-items: center; 472 justify-content: center; 473 } 474 475 .deck-thumb img { 476 width: 100%; 477 height: 100%; 478 object-fit: cover; 479 } 480 481 .thumb-placeholder { 482 color: #444; 483 } 484 485 .deck-main { 486 flex: 1; 487 display: flex; 488 justify-content: space-between; 489 align-items: center; 490 padding: 12px 16px; 491 background: transparent; 492 border: none; 493 cursor: pointer; 494 color: #fff; 495 text-align: left; 496 } 497 498 .deck-title { 499 font-weight: 500; 500 } 501 502 .deck-meta { 503 color: #666; 504 font-size: 13px; 505 } 506 507 .present-btn, .share-btn, .delete-btn { 508 padding: 8px; 509 background: transparent; 510 border: none; 511 color: #666; 512 cursor: pointer; 513 border-radius: 4px; 514 } 515 516 .delete-btn { 517 margin-right: 8px; 518 } 519 520 .present-btn:hover { 521 color: #10b981; 522 background: rgba(16, 185, 129, 0.1); 523 } 524 525 .present-btn:disabled { 526 opacity: 0.5; 527 cursor: wait; 528 } 529 530 .present-btn .spinning { 531 animation: spin 1s linear infinite; 532 } 533 534 @keyframes spin { 535 from { transform: rotate(0deg); } 536 to { transform: rotate(360deg); } 537 } 538 539 .share-btn:hover { 540 color: var(--accent, #6366f1); 541 background: rgba(99, 102, 241, 0.1); 542 } 543 544 .share-btn.copied { 545 color: #10b981; 546 } 547 548 .delete-btn:hover { 549 color: #ef4444; 550 background: rgba(239, 68, 68, 0.1); 551 } 552 553 .delete-confirm { 554 display: flex; 555 gap: 4px; 556 margin-right: 8px; 557 } 558 559 .delete-yes, .delete-no { 560 padding: 6px 10px; 561 border: none; 562 border-radius: 4px; 563 font-size: 12px; 564 cursor: pointer; 565 } 566 567 .delete-yes { 568 background: #ef4444; 569 color: #fff; 570 } 571 572 .delete-yes:hover { 573 background: #dc2626; 574 } 575 576 .delete-yes:disabled { 577 opacity: 0.6; 578 cursor: not-allowed; 579 } 580 581 .delete-no { 582 background: #333; 583 color: #ccc; 584 } 585 586 .delete-no:hover { 587 background: #444; 588 } 589 590 .editor { 591 display: flex; 592 flex-direction: column; 593 height: 100vh; 594 } 595 596 .workspace { 597 display: flex; 598 flex: 1; 599 overflow: hidden; 600 } 601 602 .canvas-area { 603 flex: 1; 604 display: flex; 605 align-items: center; 606 justify-content: center; 607 padding: 20px; 608 background: #0a0a0a; 609 } 610 611 /* Mobile: stack slide list below canvas */ 612 @media (max-width: 768px) { 613 .workspace { 614 flex-direction: column-reverse; 615 } 616 617 .canvas-area { 618 padding: 12px; 619 } 620 621 .home h1 { 622 font-size: 36px; 623 } 624 625 .home { 626 padding: 40px 16px; 627 } 628 } 629 630 .presenter-canvas { 631 position: fixed; 632 inset: 0; 633 background: #000; 634 display: flex; 635 align-items: center; 636 justify-content: center; 637 padding-bottom: var(--panel-height, 80px); 638 } 639 640 .presenter-canvas :global(.canvas) { 641 width: 100vw; 642 height: calc(100vh - var(--panel-height, 80px)); 643 max-width: 100%; 644 max-height: 100%; 645 } 646 647 .home :global(.toolbar) { 648 position: fixed; 649 top: 0; 650 left: 0; 651 right: 0; 652 } 653 654 .public-loading { 655 margin-top: 32px; 656 } 657 658 .loading-icon.small { 659 color: var(--accent, #6366f1); 660 animation: pulse 1.5s ease-in-out infinite; 661 } 662 663 .public-gallery { 664 width: 100%; 665 max-width: 800px; 666 margin-top: 32px; 667 } 668 669 .gallery-grid { 670 display: grid; 671 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 672 gap: 16px; 673 } 674 675 .gallery-card { 676 display: flex; 677 flex-direction: column; 678 background: #141414; 679 border: 1px solid #333; 680 border-radius: 8px; 681 overflow: hidden; 682 text-decoration: none; 683 color: inherit; 684 transition: border-color 0.15s ease, transform 0.15s ease; 685 } 686 687 .gallery-card:hover { 688 border-color: var(--accent, #6366f1); 689 transform: translateY(-2px); 690 } 691 692 .gallery-thumb { 693 aspect-ratio: 16 / 9; 694 background: #0f0f0f; 695 display: flex; 696 align-items: center; 697 justify-content: center; 698 overflow: hidden; 699 } 700 701 .gallery-thumb img { 702 width: 100%; 703 height: 100%; 704 object-fit: cover; 705 } 706 707 .gallery-thumb .thumb-placeholder { 708 color: #333; 709 } 710 711 .gallery-info { 712 padding: 12px; 713 display: flex; 714 flex-direction: column; 715 gap: 4px; 716 } 717 718 .gallery-title { 719 font-weight: 500; 720 font-size: 14px; 721 white-space: nowrap; 722 overflow: hidden; 723 text-overflow: ellipsis; 724 } 725 726 .gallery-meta { 727 font-size: 12px; 728 color: #666; 729 } 730 731 @media (max-width: 600px) { 732 .gallery-grid { 733 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); 734 gap: 12px; 735 } 736 737 .gallery-info { 738 padding: 10px; 739 } 740 741 .gallery-title { 742 font-size: 13px; 743 } 744 } 745</style>