powerpointproto
slides.waow.tech
slides
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>