Thread viewer for Bluesky

added main App component wrapping all pages

+94 -117
-13
index.html
··· 23 23 <link href="dist/skythread.css" rel="stylesheet"> 24 24 </head> 25 25 <body> 26 - <div id="search"></div> 27 - 28 26 <div id="github"> 29 27 <a href="https://github.com/mackuba/skythread" target="_blank"> 30 28 <img src="icons/github.png"> 31 29 </a> 32 30 </div> 33 31 34 - <div id="account_menu_wrap"></div> 35 - 36 - <div id="thread"></div> 37 - 38 32 <div id="login" class="dialog"></div> 39 - 40 33 <div id="biohazard_dialog" class="dialog"></div> 41 - 42 - <div id="posting_stats_page"></div> 43 - 44 - <div id="like_stats_page"></div> 45 - 46 - <div id="private_search_page"></div> 47 34 48 35 <script src="dist/skythread.js"></script> 49 36
+55
src/App.svelte
··· 1 + <script lang="ts"> 2 + import { account } from './models/account.svelte.js'; 3 + 4 + import AccountMenu from './components/AccountMenu.svelte'; 5 + import HashtagPage from './pages/HashtagPage.svelte'; 6 + import HomeSearch from './components/HomeSearch.svelte'; 7 + import LikeStatsPage from './pages/LikeStatsPage.svelte'; 8 + import LoginDialog from './components/LoginDialog.svelte'; 9 + import LycanSearchPage from './pages/LycanSearchPage.svelte'; 10 + import NotificationsPage from './pages/NotificationsPage.svelte'; 11 + import PostingStatsPage from './pages/PostingStatsPage.svelte'; 12 + import QuotesPage from './pages/QuotesPage.svelte'; 13 + import ThreadPage from './pages/ThreadPage.svelte'; 14 + import TimelineSearchPage from './pages/TimelineSearchPage.svelte'; 15 + 16 + let { params }: { params: Record<string, string> } = $props(); 17 + </script> 18 + 19 + <AccountMenu /> 20 + 21 + {#if params.q} 22 + <ThreadPage url={params.q} /> 23 + {:else if params.author && params.post} 24 + <ThreadPage author={params.author} rkey={params.post} /> 25 + {:else if params.quotes} 26 + <QuotesPage postURL={params.quotes} /> 27 + {:else if params.hash} 28 + <HashtagPage hashtag={params.hash} /> 29 + {:else if params.page} 30 + {#if account.loggedIn} 31 + {@render page(params.page)} 32 + {:else} 33 + <LoginDialog /> 34 + {/if} 35 + {:else} 36 + <HomeSearch /> 37 + {/if} 38 + 39 + {#snippet page(name)} 40 + {#if params.page == 'notif'} 41 + <NotificationsPage /> 42 + {:else if params.page == 'posting_stats'} 43 + <PostingStatsPage /> 44 + {:else if params.page == 'like_stats'} 45 + <LikeStatsPage /> 46 + {:else if params.page == 'search'} 47 + {#if params.mode == 'likes'} 48 + <LycanSearchPage lycan={params.lycan} /> 49 + {:else} 50 + <TimelineSearchPage /> 51 + {/if} 52 + {:else} 53 + <HomeSearch /> 54 + {/if} 55 + {/snippet}
+5 -3
src/components/HomeSearch.svelte
··· 38 38 } 39 39 </script> 40 40 41 - <form method="get" {onsubmit}> 42 - 🌤 <input type="text" placeholder="Paste a thread link or type a #hashtag" bind:value={query} bind:this={searchField}> 43 - </form> 41 + <div id="search"> 42 + <form method="get" {onsubmit}> 43 + 🌤 <input type="text" placeholder="Paste a thread link or type a #hashtag" bind:value={query} bind:this={searchField}> 44 + </form> 45 + </div> 44 46 45 47 <style> 46 48 form {
+2
src/pages/HashtagPage.svelte
··· 46 46 </svelte:head> 47 47 48 48 {#if firstPageLoaded} 49 + <div id="thread" class="hashtag"> 49 50 <header> 50 51 <h2> 51 52 {#if posts.length > 0} ··· 59 60 {#each posts as post (post.uri)} 60 61 <PostComponent {post} placement="feed" /> 61 62 {/each} 63 + </div> 62 64 {:else if !loadingFailed} 63 65 <MainLoader /> 64 66 {/if}
+2
src/pages/LikeStatsPage.svelte
··· 36 36 } 37 37 </script> 38 38 39 + <div id="like_stats_page"> 39 40 <h2>Like statistics</h2> 40 41 41 42 <form onsubmit={startScan}> ··· 57 58 <LikeStatsTable cssClass="given-likes" header="❤️ Likes from you:" users={givenLikesUsers} /> 58 59 <LikeStatsTable cssClass="received-likes" header="💛 Likes on your posts:" users={receivedLikesUsers} /> 59 60 {/if} 61 + </div> 60 62 61 63 <style> 62 64 input[type="range"] {
+9 -5
src/pages/LycanSearchPage.svelte
··· 1 1 <script lang="ts"> 2 2 import { Post } from '../models/posts'; 3 3 import { settings } from '../models/settings.svelte'; 4 - import { Lycan } from '../services/lycan'; 4 + import { DevLycan, Lycan } from '../services/lycan'; 5 5 import PostComponent from '../components/posts/PostComponent.svelte'; 6 6 7 7 const collections = [ ··· 11 11 { id: 'pins', title: 'Pins' } 12 12 ] 13 13 14 - let { lycan }: { lycan: Lycan } = $props(); 14 + let { lycan }: { lycan: string } = $props(); 15 + 16 + let lycanService = $derived(lycan == 'local' ? new DevLycan() : new Lycan()); 15 17 16 18 let isCheckingStatus = $state(false); 17 19 let importStatus: string | undefined = $state(); ··· 37 39 showImportStatus({ status: 'requested' }); 38 40 wasImporting = true; 39 41 40 - lycan.startImport().catch((error) => { 42 + lycanService.startImport().catch((error) => { 41 43 console.error('Failed to start Lycan import', error); 42 44 showImportError(`Import failed: ${error}`); 43 45 }); ··· 58 60 loadingPosts = true; 59 61 finishedPosts = false; 60 62 61 - lycan.searchPosts(selectedCollection, q, { 63 + lycanService.searchPosts(selectedCollection, q, { 62 64 onPostsLoaded: ({ posts, terms }) => { 63 65 loadingPosts = false; 64 66 results.push(...posts); ··· 79 81 isCheckingStatus = true; 80 82 81 83 try { 82 - let response = await lycan.getImportStatus(); 84 + let response = await lycanService.getImportStatus(); 83 85 showImportStatus(response); 84 86 } catch (error) { 85 87 showImportError(`Couldn't check import status: ${error}`); ··· 150 152 } 151 153 </script> 152 154 155 + <div id="private_search_page"> 153 156 <h2>Archive search</h2> 154 157 155 158 <form class="search-form"> ··· 214 217 {/if} 215 218 {/if} 216 219 </div> 220 + </div>
+2
src/pages/NotificationsPage.svelte
··· 49 49 </svelte:head> 50 50 51 51 {#if firstPageLoaded} 52 + <div id="thread" class="notifications"> 52 53 <header> 53 54 <h2>Replies & Mentions:</h2> 54 55 </header> ··· 61 62 62 63 <PostComponent {post} placement="feed" /> 63 64 {/each} 65 + </div> 64 66 {:else if !loadingFailed} 65 67 <MainLoader /> 66 68 {/if}
+2
src/pages/PostingStatsPage.svelte
··· 104 104 } 105 105 </script> 106 106 107 + <div id="posting_stats_page"> 107 108 <h2>Bluesky posting statistics</h2> 108 109 109 110 <form {onsubmit}> ··· 152 153 {#if results} 153 154 <PostingStatsTable {...tableOptions} {...results} /> 154 155 {/if} 156 + </div>
+2
src/pages/QuotesPage.svelte
··· 48 48 </script> 49 49 50 50 {#if quoteCount !== undefined} 51 + <div id="thread" class="quotes"> 51 52 <header> 52 53 <h2> 53 54 {#if quoteCount > 1} ··· 68 69 69 70 <PostComponent {post} placement="quotes" /> 70 71 {/each} 72 + </div> 71 73 {:else if !loadingFailed} 72 74 <MainLoader /> 73 75 {/if}
+2
src/pages/ThreadPage.svelte
··· 60 60 </svelte:head> 61 61 62 62 {#if post} 63 + <div id="thread"> 63 64 {#if post instanceof Post} 64 65 {#if post.parent} 65 66 <ThreadRootParent post={post.parent} /> ··· 71 72 {:else} 72 73 <PostWrapper {post} placement="thread" /> 73 74 {/if} 75 + </div> 74 76 {:else if !loadingFailed} 75 77 <MainLoader /> 76 78 {/if}
+2
src/pages/TimelineSearchPage.svelte
··· 48 48 } 49 49 </script> 50 50 51 + <div id="private_search_page"> 51 52 <h2>Timeline search</h2> 52 53 53 54 <div class="timeline-search"> ··· 89 90 {/each} 90 91 </div> 91 92 {/if} 93 + </div>
+6
src/router.js
··· 65 65 66 66 return { user, post }; 67 67 } 68 + 69 + /** @param {string} urlQuery, @returns {Record<string, string>} */ 70 + 71 + export function parseURLParams(urlQuery) { 72 + return Object.fromEntries(new URLSearchParams(urlQuery)); 73 + }
+5 -83
src/skythread.ts
··· 1 1 import * as svelte from 'svelte'; 2 - import AccountMenu from './components/AccountMenu.svelte'; 2 + import App from './App.svelte'; 3 3 import BiohazardDialog from './components/BiohazardDialog.svelte'; 4 - import HashtagPage from './pages/HashtagPage.svelte'; 5 - import HomeSearch from './components/HomeSearch.svelte'; 6 4 import LoginDialog from './components/LoginDialog.svelte'; 7 - import LikeStatsPage from './pages/LikeStatsPage.svelte'; 8 - import LycanSearchPage from './pages/LycanSearchPage.svelte'; 9 - import NotificationsPage from './pages/NotificationsPage.svelte'; 10 - import PostingStatsPage from './pages/PostingStatsPage.svelte'; 11 - import QuotesPage from './pages/QuotesPage.svelte'; 12 - import TimelineSearchPage from './pages/TimelineSearchPage.svelte'; 13 - import ThreadPage from './pages/ThreadPage.svelte'; 14 5 15 - import { BlueskyAPI, accountAPI } from './api.js'; 6 + import { BlueskyAPI } from './api.js'; 16 7 import { account } from './models/account.svelte.js'; 17 - import { Lycan, DevLycan } from './services/lycan.js'; 8 + import { parseURLParams } from './router.js'; 18 9 19 10 let loginDialog: Record<string, any> | undefined; 20 11 let biohazardDialog: Record<string, any> | undefined; 21 12 22 13 23 14 function init() { 24 - svelte.mount(AccountMenu, { target: document.getElementById('account_menu_wrap')! }); 25 - 26 15 for (let dialog of document.querySelectorAll('.dialog')) { 27 16 let close = dialog.querySelector('.close') as HTMLElement; 28 17 ··· 43 32 } 44 33 45 34 function parseQueryParams() { 46 - let params = new URLSearchParams(location.search); 47 - let { q, author, post, quotes, hash, page } = Object.fromEntries(params); 48 - 49 - if (quotes) { 50 - loadQuotesPage(decodeURIComponent(quotes)); 51 - } else if (hash) { 52 - loadHashtagPage(decodeURIComponent(hash)); 53 - } else if (q) { 54 - svelte.mount(ThreadPage, { target: document.getElementById('thread')!, props: { url: q }}); 55 - } else if (author && post) { 56 - svelte.mount(ThreadPage, { target: document.getElementById('thread')!, props: { author: author, rkey: post }}); 57 - } else if (page) { 58 - openPage(page); 59 - } else { 60 - showSearch(); 61 - } 62 - } 63 - 64 - function showSearch() { 65 - let search = document.getElementById('search')! 66 - svelte.mount(HomeSearch, { target: search }); 67 - search.style.visibility = 'visible'; 35 + let params = parseURLParams(location.search); 36 + svelte.mount(App, { target: document.body, props: { params }}); 68 37 } 69 38 70 39 function hideDialog(dialog) { ··· 159 128 if (page) { 160 129 openPage(page); 161 130 } 162 - } 163 - 164 - function openPage(page: string) { 165 - if (!accountAPI.isLoggedIn) { 166 - showLoginDialog(false); 167 - return; 168 - } 169 - 170 - if (page == 'notif') { 171 - let div = document.getElementById('thread')! 172 - div.classList.add('notifications'); 173 - svelte.mount(NotificationsPage, { target: div }); 174 - } else if (page == 'posting_stats') { 175 - let div = document.getElementById('posting_stats_page')! 176 - svelte.mount(PostingStatsPage, { target: div }); 177 - div.style.display = 'block'; 178 - } else if (page == 'like_stats') { 179 - let div = document.getElementById('like_stats_page')! 180 - svelte.mount(LikeStatsPage, { target: div }); 181 - div.style.display = 'block'; 182 - } else if (page == 'search') { 183 - let params = new URLSearchParams(location.search); 184 - let div = document.getElementById('private_search_page')! 185 - 186 - if (params.get('mode') == 'likes') { 187 - let lycan = (params.get('lycan') == 'local') ? new DevLycan() : new Lycan(); 188 - svelte.mount(LycanSearchPage, { target: div, props: { lycan }}); 189 - } else { 190 - svelte.mount(TimelineSearchPage, { target: div }); 191 - } 192 - 193 - div.style.display = 'block'; 194 - } 195 - } 196 - 197 - function loadHashtagPage(hashtag: string) { 198 - let div = document.getElementById('thread')! 199 - div.classList.add('hashtag'); 200 - 201 - svelte.mount(HashtagPage, { target: div, props: { hashtag }}); 202 - } 203 - 204 - function loadQuotesPage(postURL: string) { 205 - let div = document.getElementById('thread')! 206 - div.classList.add('quotes'); 207 - 208 - svelte.mount(QuotesPage, { target: div, props: { postURL }}); 209 131 } 210 132 211 133 window.init = init;
-13
style.css
··· 51 51 } 52 52 53 53 #search { 54 - visibility: hidden; 55 54 position: fixed; 56 55 top: 0; 57 56 bottom: 0; ··· 701 700 margin-top: 25px; 702 701 } 703 702 704 - #posting_stats_page { 705 - display: none; 706 - } 707 - 708 703 #posting_stats_page input[type="radio"] { 709 704 position: relative; 710 705 top: -1px; ··· 892 887 893 888 #posting_stats_page .scan-result td.percent { 894 889 min-width: 70px; 895 - } 896 - 897 - #like_stats_page { 898 - display: none; 899 - } 900 - 901 - #private_search_page { 902 - display: none; 903 890 } 904 891 905 892 #private_search_page input[type="range"] {