A fork of pds-dash for selfhosted.social

selfhosted.social changes

Themeing

sponsor link

work done

Updated readme

+37
README.md
··· 1 1 # pds-dash 2 2 3 + A fork of [pds-dash](https://git.witchcraft.systems/scientific-witchery/pds-dash) for [selfhosted.social](https://selfhosted.social). The top part of the readme is about this fork. 4 + See after [Original Readme](#original-readme) to see the original readme for setup 5 + 6 + This fork is much the same but a few differences: 7 + - [New theme](/themes/dark/theme.css) 8 + - Uses the CDN for loading images and videos instead of `com.atproto.sync.getBlob` 9 + - Caches a couple of things like did -> handle and PDS user profile lexicon inside localstorage. Not the best, but was simpler and has a expire on get. 10 + - The text "Home to x accounts" only shows active accounts. 11 + - I did add a sponsor button for my GitHub. 12 + 13 + An example of a caddy file you can use 14 + ```caddyfile 15 + # Should be all the endpoints a PDS calls 16 + @pds { 17 + path /xrpc/* 18 + path /account/* 19 + path /.well-known/* 20 + path /@atproto/* 21 + path /oauth/* 22 + } 23 + 24 + handle @pds { 25 + reverse_proxy http://localhost:3000 26 + } 27 + 28 + # If none matches goes to landing page 29 + handle /* { 30 + root * /srv/landing 31 + try_files {path} /index.html 32 + file_server 33 + } 34 + 35 + ``` 36 + 37 + 38 + # Original Readme 39 + 3 40 a frontend dashboard with stats for your ATProto PDS. 4 41 5 42 ## setup
+5
deno.lock
··· 6 6 "npm:@atcute/identity-resolver@~0.1.2": "0.1.2_@atcute+identity@0.1.3", 7 7 "npm:@sveltejs/vite-plugin-svelte@^5.0.3": "5.0.3_svelte@5.28.1__acorn@8.14.1_vite@6.3.2__picomatch@4.0.2", 8 8 "npm:@tsconfig/svelte@^5.0.4": "5.0.4", 9 + "npm:hls.js@^1.6.12": "1.6.12", 9 10 "npm:moment@^2.30.1": "2.30.1", 10 11 "npm:mutex-ts@^1.2.1": "1.2.1", 11 12 "npm:svelte-check@^4.1.5": "4.1.6_svelte@5.28.1__acorn@8.14.1_typescript@5.7.3", ··· 420 421 "os": ["darwin"], 421 422 "scripts": true 422 423 }, 424 + "hls.js@1.6.12": { 425 + "integrity": "sha512-Pz+7IzvkbAht/zXvwLzA/stUHNqztqKvlLbfpq6ZYU68+gZ+CZMlsbQBPUviRap+3IQ41E39ke7Ia+yvhsehEQ==" 426 + }, 423 427 "is-reference@3.0.3": { 424 428 "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", 425 429 "dependencies": [ ··· 592 596 "npm:@atcute/identity-resolver@~0.1.2", 593 597 "npm:@sveltejs/vite-plugin-svelte@^5.0.3", 594 598 "npm:@tsconfig/svelte@^5.0.4", 599 + "npm:hls.js@^1.6.12", 595 600 "npm:moment@^2.30.1", 596 601 "npm:mutex-ts@^1.2.1", 597 602 "npm:svelte-check@^4.1.5",
+3 -1
index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <title>ATProto PDS</title> 6 + <title>Selfhosted.social</title> 7 + <meta name="description" content="Landing page for selfhosted.social, a ATProto PDS"> 8 + 7 9 </head> 8 10 <body> 9 11 <div id="app"></div>
+1
package.json
··· 13 13 "@atcute/bluesky": "^2.0.2", 14 14 "@atcute/client": "^3.0.1", 15 15 "@atcute/identity-resolver": "^0.1.2", 16 + "hls.js": "^1.6.12", 16 17 "moment": "^2.30.1", 17 18 "mutex-ts": "^1.2.1", 18 19 "svelte-infinite-loading": "^1.4.0"
public/favicon.ico

This is a binary file and will not be displayed.

public/moo.webp

This is a binary file and will not be displayed.

public/unknown.png

This is a binary file and will not be displayed.

+15 -8
src/App.svelte
··· 7 7 const accountsPromise = getAllMetadataFromPds(); 8 8 import { onMount } from "svelte"; 9 9 10 + 11 + 10 12 let posts: Post[] = []; 11 13 12 14 let hue: number = 1; 13 15 const cycleColors = async () => { 14 - while (true) { 16 + while (true) { 15 17 hue += 1; 16 18 if (hue > 360) { 17 19 hue = 0; ··· 30 32 }; 31 33 32 34 onMount(() => { 33 - // Fetch initial posts 34 - getNextPosts().then((initialPosts) => { 35 - posts = initialPosts; 36 - }); 35 + // Fetch initial posts 36 + // TODO I think this was getting called twice? 37 + // getNextPosts().then((initialPosts) => { 38 + // posts = initialPosts; 39 + // }); 37 40 }); 38 41 // Infinite loading function 39 42 const onInfinite = ({ ··· 60 63 {:then accountsData} 61 64 <div id="Account"> 62 65 <h1 onclick={carameldansenfusion} id="Header">ATProto PDS</h1> 63 - <p>Home to {accountsData.length} accounts</p> 66 + <p>Home to {accountsData.length} accounts</p> 64 67 <div id="accountsList"> 65 68 {#each accountsData as accountObject} 66 69 <AccountComponent account={accountObject} /> 67 70 {/each} 68 71 </div> 72 + <div style="margin: 8px 0 12px;"> 73 + <p>Help support the PDS</p> 74 + <iframe src="https://github.com/sponsors/fatfingers23/button" title="Sponsor fatfingers23" height="32" width="114" style="border: 0; border-radius: 6px;"></iframe> 75 + </div> 69 76 <p>{@html Config.FOOTER_TEXT}</p> 70 77 </div> 71 78 {:catch error} ··· 75 82 <div id="Feed"> 76 83 <div id="spacer"></div> 77 84 {#each posts as postObject} 78 - <PostComponent post={postObject as Post} /> 85 + <PostComponent post={postObject} /> 79 86 {/each} 80 87 <InfiniteLoading on:infinite={onInfinite} distance={3000} /> 81 88 <div id="spacer"></div> ··· 84 91 </main> 85 92 86 93 <style> 87 - 94 + 88 95 </style>
+6 -1
src/lib/AccountComponent.svelte
··· 10 10 <img 11 11 id="avatar" 12 12 alt="avatar of {account.displayName}" 13 - src="{Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did={account.did}&cid={account.avatarCid}" 13 + src="https://cdn.bsky.app/img/feed_thumbnail/plain/{account.did}/{account.avatarCid}@jpeg" 14 14 /> 15 15 <div id="accountName"> 16 16 {account.displayName || account.handle || account.did} 17 17 </div> 18 18 {:else} 19 + <img 20 + id="avatar" 21 + alt="unknown avatar of {account.displayName}" 22 + src="/unknown.png" 23 + /> 19 24 <div id="accountName" class="no-avatar"> 20 25 {account.displayName || account.handle || account.did} 21 26 </div>
+60 -23
src/lib/PostComponent.svelte
··· 1 1 <script lang="ts"> 2 2 import { Post } from "./pdsfetch"; 3 3 import { Config } from "../../config"; 4 - import { onMount } from "svelte"; 4 + import { onMount, onDestroy } from "svelte"; 5 5 import moment from "moment"; 6 - 6 + import { blueskyHandleFromDid } from "./pdsfetch"; 7 + import Hls from "hls.js"; 7 8 let { post }: { post: Post } = $props(); 8 9 9 10 // State for image carousel 10 11 let currentImageIndex = $state(0); 11 12 13 + // Local state for reply handle text 14 + let replyingHandle: string | null = $state(null); 15 + 16 + // Video element ref and HLS instance 17 + let videoEl: HTMLVideoElement | null = $state(null); 18 + let hls: Hls | null = null; 19 + 20 + // Update replying handle when replyingUri changes 21 + $effect(() => { 22 + if (post.replyingUri?.repo) { 23 + // fire and forget; update when resolved 24 + blueskyHandleFromDid(post.replyingUri.repo) 25 + .then((h) => { 26 + replyingHandle = h || null; 27 + }) 28 + .catch(() => { 29 + replyingHandle = null; 30 + }); 31 + } else { 32 + replyingHandle = null; 33 + } 34 + }); 35 + 12 36 // Functions to navigate carousel 13 37 function nextImage() { 14 38 if (post.imagesCid && currentImageIndex < post.imagesCid.length - 1) { ··· 27 51 if (!post.imagesCid || index < 0 || index >= post.imagesCid.length) return; 28 52 29 53 const img = new Image(); 30 - img.src = `${Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did=${post.authorDid}&cid=${post.imagesCid[index]}`; 54 + img.src = `https://cdn.bsky.app/img/feed_thumbnail/plain/${post.authorDid}/${post.imagesCid[index]}@jpeg`; 31 55 } 32 56 33 - // Preload adjacent images when current index changes 34 - $effect(() => { 57 + // Initialize HLS playback when mounted if there's a video 58 + onMount(() => { 59 + // Preload the next image if it exists 35 60 if (post.imagesCid && post.imagesCid.length > 1) { 36 - // Preload next image if available 37 - if (currentImageIndex < post.imagesCid.length - 1) { 38 - preloadImage(currentImageIndex + 1); 61 + if (post.imagesCid.length > 1) { 62 + preloadImage(1); 39 63 } 64 + } 40 65 41 - // Preload previous image if available 42 - if (currentImageIndex > 0) { 43 - preloadImage(currentImageIndex - 1); 66 + if (post.videosLinkCid && videoEl) { 67 + const src = `https://video.cdn.bsky.app/hls/${post.authorDid}/${post.videosLinkCid}/playlist.m3u8`; 68 + try { 69 + if (Hls.isSupported()) { 70 + hls = new Hls(); 71 + hls.loadSource(src); 72 + hls.attachMedia(videoEl); 73 + } else if (videoEl.canPlayType("application/vnd.apple.mpegurl")) { 74 + // Safari / iOS native HLS 75 + videoEl.src = src; 76 + } else { 77 + // As a basic fallback, set src; some browsers may still handle it 78 + videoEl.src = src; 79 + } 80 + } catch (_) { 81 + // Ignore init errors; controls will remain and user can retry 44 82 } 45 83 } 46 84 }); 47 85 48 - // Initial preload of images 49 - onMount(() => { 50 - if (post.imagesCid && post.imagesCid.length > 1) { 51 - // Preload the next image if it exists 52 - if (post.imagesCid.length > 1) { 53 - preloadImage(1); 54 - } 86 + onDestroy(() => { 87 + if (hls) { 88 + hls.destroy(); 89 + hls = null; 55 90 } 56 91 }); 57 92 </script> ··· 61 96 {#if post.authorAvatarCid} 62 97 <img 63 98 id="avatar" 64 - src="{Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did={post.authorDid}&cid={post.authorAvatarCid}" 99 + src="https://cdn.bsky.app/img/feed_thumbnail/plain/{post.authorDid}/{post.authorAvatarCid}@jpeg" 65 100 alt="avatar of {post.displayName}" 66 101 /> 67 102 {/if} ··· 76 111 77 112 <a 78 113 id="postLink" 114 + style="text-decoration: underline;" 79 115 href="{Config.FRONTEND_URL}/profile/{post.authorDid}/post/{post.recordName}" 80 116 >{moment(post.timenotstamp).isBefore(moment().subtract(1, "month")) 81 117 ? moment(post.timenotstamp).format("MMM D, YYYY") ··· 88 124 {#if post.replyingUri} 89 125 <a 90 126 id="replyingText" 127 + style="text-decoration: underline;" 91 128 href="{Config.FRONTEND_URL}/profile/{post.replyingUri.repo}/post/{post 92 - .replyingUri.rkey}">replying to {post.replyingUri.repo}</a 129 + .replyingUri.rkey}">replying to {replyingHandle ? `@${replyingHandle}` : post.replyingUri.repo}</a 93 130 > 94 131 {/if} 95 132 {#if post.quotingUri} ··· 105 142 <img 106 143 id="embedImages" 107 144 alt="Post Image {currentImageIndex + 1} of {post.imagesCid.length}" 108 - src="{Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did={post.authorDid}&cid={post 109 - .imagesCid[currentImageIndex]}" 145 + src="https://cdn.bsky.app/img/feed_thumbnail/plain/{post.authorDid}/{post 146 + .imagesCid[currentImageIndex]}@jpeg" 110 147 /> 111 148 112 149 {#if post.imagesCid.length > 1} ··· 137 174 <!-- svelte-ignore a11y_media_has_caption --> 138 175 <video 139 176 id="embedVideo" 140 - src="{Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did={post.authorDid}&cid={post.videosLinkCid}" 177 + bind:this={videoEl} 141 178 controls 142 179 ></video> 143 180 {/if}
+66 -5
src/lib/pdsfetch.ts
··· 14 14 } from "@atcute/identity-resolver"; 15 15 import { Config } from "../../config"; 16 16 import { Mutex } from "mutex-ts" 17 + import type {DidDocument} from "@atcute/client/utils/did"; 17 18 // import { ComAtprotoRepoListRecords.Record } from "@atcute/client/lexicons"; 18 19 // import { AppBskyFeedPost } from "@atcute/client/lexicons"; 19 20 // import { AppBskyActorDefs } from "@atcute/client/lexicons"; ··· 117 118 }; 118 119 }; 119 120 121 + 120 122 const rpc = new XRPC({ 121 123 handler: simpleFetchHandler({ 122 124 service: Config.PDS_URL, 123 125 }), 124 126 }); 125 127 128 + const slingShot = new XRPC({ 129 + handler: simpleFetchHandler({ 130 + service: "https://slingshot.microcosm.blue", 131 + }), 132 + }); 133 + 126 134 const getDidsFromPDS = async (): Promise<At.Did[]> => { 127 135 const { data } = await rpc.get("com.atproto.sync.listRepos", { 128 - params: {}, 136 + params: { 137 + limit: 1000, 138 + }, 129 139 }); 130 - return data.repos.map((repo: any) => repo.did) as At.Did[]; 140 + return data.repos.filter(x => x.active).map((repo: any) => repo.did) as At.Did[]; 131 141 }; 132 142 const getAccountMetadata = async ( 133 143 did: `did:${string}:${string}`, ··· 138 148 displayName: "", 139 149 avatarCid: null, 140 150 }; 141 - 151 + const localStorageKey = `did-metadata:${did}`; 152 + const cachedResult = cacheGet<AccountMetadata>(localStorageKey); 153 + if (cachedResult) { 154 + return cachedResult; 155 + } 142 156 try { 143 157 const { data } = await rpc.get("com.atproto.repo.getRecord", { 144 158 params: { ··· 162 176 console.error(`Error fetching handle for ${did}:`, e); 163 177 return null; 164 178 } 165 - 179 + cacheSet<AccountMetadata>(localStorageKey, account); 166 180 return account; 167 181 }; 168 182 ··· 195 209 }; 196 210 197 211 const blueskyHandleFromDid = async (did: At.Did) => { 212 + const localStorageKey = `did-handle:${did}`; 213 + const cachedResult = cacheGet<string>(localStorageKey); 214 + if (cachedResult) { 215 + return cachedResult; 216 + } 198 217 const doc = await identityResolve(did); 199 218 if (doc.alsoKnownAs) { 200 219 const handleAtUri = doc.alsoKnownAs.find((url) => url.startsWith("at://")); ··· 202 221 if (!handle) { 203 222 return "Handle not found"; 204 223 } else { 224 + cacheSet<string>(localStorageKey, handle); 205 225 return handle; 206 226 } 207 227 } else { ··· 355 375 } 356 376 }; 357 377 358 - export { getAllMetadataFromPds, getNextPosts, Post }; 378 + type CacheEntry<T> = { 379 + data: T; 380 + expire_timestamp: number; 381 + } 382 + 383 + 384 + const cacheSet = <T>(key: string, value: T) => { 385 + try{ 386 + const day = 60 * 60 * 24 * 1000; 387 + const cacheData: CacheEntry<T> = { 388 + data: value, 389 + expire_timestamp: Date.now() + day 390 + } 391 + localStorage.setItem(key, JSON.stringify(cacheData)); 392 + } 393 + catch(e){ 394 + console.error("Error caching data:", e); 395 + //Going just clear the cache and assume it's full. 396 + localStorage.clear(); 397 + } 398 + } 399 + 400 + const cacheGet = <T>(key: string): T | null => { 401 + try{ 402 + const cachedData = localStorage.getItem(key); 403 + if (cachedData) { 404 + const parsedData = JSON.parse(cachedData) as CacheEntry<T>; 405 + if (parsedData.expire_timestamp > Date.now() ) { 406 + return parsedData.data; 407 + } else { 408 + localStorage.removeItem(key); 409 + } 410 + } 411 + //Return null if empty or expired 412 + return null; 413 + }catch(e){ 414 + console.error("Error fetching data from cache:", e); 415 + return null; 416 + } 417 + } 418 + 419 + export { getAllMetadataFromPds, getNextPosts, Post, blueskyHandleFromDid }; 359 420 export type { AccountMetadata };
+514
themes/dark/theme.css
··· 1 + /* Modern Theme for pds-dash */ 2 + 3 + :root { 4 + /* Dark theme derived from provided OKLCH palette */ 5 + color-scheme: dark; 6 + 7 + /* Base and content colors */ 8 + --color-base-100: oklch(25.33% 0.016 252.42); 9 + --color-base-200: oklch(23.26% 0.014 253.1); 10 + --color-base-300: oklch(21.15% 0.012 254.09); 11 + --color-base-content: oklch(97.807% 0.029 256.847); 12 + 13 + /* Brand and semantic colors */ 14 + --color-primary: oklch(58% 0.233 277.117); 15 + --color-primary-content: oklch(96% 0.018 272.314); 16 + --color-secondary: oklch(65% 0.241 354.308); 17 + --color-secondary-content: oklch(94% 0.028 342.258); 18 + --color-accent: oklch(77% 0.152 181.912); 19 + --color-accent-content: oklch(38% 0.063 188.416); 20 + --color-neutral: oklch(14% 0.005 285.823); 21 + --color-neutral-content: oklch(92% 0.004 286.32); 22 + --color-info: oklch(74% 0.16 232.661); 23 + --color-info-content: oklch(29% 0.066 243.157); 24 + --color-success: oklch(76% 0.177 163.223); 25 + --color-success-content: oklch(37% 0.077 168.94); 26 + --color-warning: oklch(82% 0.189 84.429); 27 + --color-warning-content: oklch(41% 0.112 45.904); 28 + --color-error: oklch(71% 0.194 13.428); 29 + --color-error-content: oklch(27% 0.105 12.094); 30 + 31 + /* Radii, sizes, borders */ 32 + --radius-selector: 0.5rem; 33 + --radius-field: 0.25rem; 34 + --radius-box: 0.5rem; 35 + --size-selector: 0.25rem; 36 + --size-field: 0.25rem; 37 + --border: 1px; 38 + --depth: 1; 39 + --noise: 0; 40 + 41 + /* Map existing theme variables to the new palette for minimal changes elsewhere */ 42 + --background-color: var(--color-base-300); 43 + --header-background-color: var(--color-base-200); 44 + --content-background-color: var(--color-base-200); 45 + --text-color: var(--color-base-content); 46 + --text-secondary-color: color-mix(in oklab, var(--color-base-content) 70%, var(--color-base-100)); 47 + --border-color: var(--color-base-300); 48 + --link-color: var(--color-primary); 49 + --link-hover-color: var(--color-primary-content); 50 + --time-color: var(--color-secondary); 51 + --indicator-inactive-color: var(--color-base-300); 52 + --indicator-active-color: var(--color-primary); 53 + 54 + /* Subtle hover background for dark */ 55 + --button-hover: color-mix(in oklab, var(--color-base-200) 80%, var(--color-base-content)); 56 + } 57 + 58 + 59 + body { 60 + margin: 0; 61 + display: flex; 62 + place-items: center; 63 + min-width: 320px; 64 + min-height: 100vh; 65 + background-color: var(--background-color); 66 + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; 67 + font-size: 18px; 68 + line-height: 1.5; 69 + color: var(--text-color); 70 + border-color: var(--border-color); 71 + overflow-wrap: break-word; 72 + word-break: break-word; 73 + hyphens: none; 74 + } 75 + 76 + a { 77 + font-weight: 500; 78 + color: var(--link-color); 79 + text-decoration: none; 80 + transition: color 0.15s ease; 81 + } 82 + a:hover { 83 + color: var(--link-hover-color); 84 + } 85 + 86 + h1 { 87 + font-size: 2.5em; 88 + line-height: 1.2; 89 + font-weight: 700; 90 + } 91 + 92 + #app { 93 + max-width: 1400px; 94 + width: 100%; 95 + margin: 0 auto; 96 + padding: 0; 97 + text-align: center; 98 + } 99 + 100 + /* Post Component */ 101 + #postContainer { 102 + display: flex; 103 + flex-direction: column; 104 + border-radius: 12px; 105 + border: 1px solid var(--border-color); 106 + background-color: var(--content-background-color); 107 + margin-bottom: 20px; 108 + overflow-wrap: break-word; 109 + overflow: hidden; 110 + box-shadow: var(--card-shadow); 111 + transition: transform 0.2s ease, box-shadow 0.2s ease; 112 + } 113 + 114 + #postContainer:hover { 115 + transform: translateY(-2px); 116 + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); 117 + } 118 + 119 + #postHeader { 120 + display: flex; 121 + flex-direction: row; 122 + align-items: center; 123 + justify-content: start; 124 + background-color: var(--header-background-color); 125 + padding: 12px 16px; 126 + height: 60px; 127 + border-bottom: 1px solid var(--border-color); 128 + font-weight: 600; 129 + overflow-wrap: break-word; 130 + } 131 + 132 + #displayName { 133 + display: block; 134 + color: var(--text-color); 135 + font-size: 1.1em; 136 + padding: 0; 137 + margin: 0 0 2px 0; 138 + text-overflow: ellipsis; 139 + overflow: hidden; 140 + white-space: nowrap; 141 + width: 100%; 142 + letter-spacing: -0.01em; 143 + } 144 + 145 + #handle { 146 + display: flex; 147 + align-items: center; 148 + color: #6b7280; 149 + font-size: 0.85em; 150 + font-weight: 400; 151 + padding: 0; 152 + margin: 0; 153 + gap: 8px; 154 + } 155 + 156 + #postLink { 157 + color: var(--time-color); 158 + font-size: 0.85em; 159 + padding: 0; 160 + margin: 0; 161 + opacity: 0.9; 162 + } 163 + 164 + #postContent { 165 + display: flex; 166 + text-align: start; 167 + flex-direction: column; 168 + padding: 16px; 169 + background-color: var(--content-background-color); 170 + color: var(--text-color); 171 + overflow-wrap: break-word; 172 + white-space: pre-line; 173 + line-height: 1.6; 174 + } 175 + 176 + #replyingText, #quotingText { 177 + font-size: 0.8em; 178 + margin: 0; 179 + padding: 0 0 10px 0; 180 + color: #6b7280; 181 + } 182 + 183 + #postText { 184 + margin: 0 0 8px 0; 185 + padding: 0; 186 + overflow-wrap: break-word; 187 + word-break: break-word; 188 + hyphens: none; 189 + font-size: 1.05em; 190 + } 191 + 192 + #headerText { 193 + margin-left: 12px; 194 + font-size: 0.9em; 195 + text-align: start; 196 + word-break: break-word; 197 + max-width: 80%; 198 + max-height: 95%; 199 + overflow: hidden; 200 + align-self: flex-start; 201 + margin-top: auto; 202 + margin-bottom: auto; 203 + } 204 + 205 + #carouselContainer { 206 + position: relative; 207 + width: 100%; 208 + margin-top: 12px; 209 + display: flex; 210 + flex-direction: column; 211 + align-items: center; 212 + border-radius: 8px; 213 + overflow: hidden; 214 + } 215 + 216 + #carouselControls { 217 + display: flex; 218 + justify-content: space-between; 219 + align-items: center; 220 + width: 100%; 221 + max-width: 500px; 222 + margin-top: 10px; 223 + } 224 + 225 + #carouselIndicators { 226 + display: flex; 227 + gap: 6px; 228 + } 229 + 230 + .indicator { 231 + width: 6px; 232 + height: 6px; 233 + background-color: var(--indicator-inactive-color); 234 + border-radius: 50%; 235 + transition: background-color 0.2s ease, transform 0.2s ease; 236 + } 237 + 238 + .indicator.active { 239 + background-color: var(--indicator-active-color); 240 + transform: scale(1.3); 241 + } 242 + 243 + #prevBtn, 244 + #nextBtn { 245 + background-color: var(--button-bg); 246 + color: var(--text-color); 247 + border: 1px solid var(--border-color); 248 + width: 32px; 249 + height: 32px; 250 + cursor: pointer; 251 + display: flex; 252 + align-items: center; 253 + justify-content: center; 254 + border-radius: 50%; 255 + transition: background-color 0.15s ease, transform 0.15s ease; 256 + font-size: 16px; 257 + } 258 + 259 + #prevBtn:hover:not(:disabled), 260 + #nextBtn:hover:not(:disabled) { 261 + background-color: var(--button-hover); 262 + transform: scale(1.05); 263 + } 264 + 265 + #prevBtn:disabled, 266 + #nextBtn:disabled { 267 + opacity: 0.4; 268 + cursor: not-allowed; 269 + } 270 + 271 + #embedVideo { 272 + width: 100%; 273 + max-width: 500px; 274 + margin-top: 12px; 275 + align-self: center; 276 + border-radius: 8px; 277 + overflow: hidden; 278 + } 279 + 280 + #embedImages { 281 + min-width: min(100%, 500px); 282 + max-width: min(100%, 500px); 283 + max-height: 500px; 284 + object-fit: contain; 285 + margin: 0; 286 + border-radius: 8px; 287 + } 288 + 289 + /* Account Component */ 290 + #accountContainer { 291 + display: flex; 292 + text-align: start; 293 + align-items: center; 294 + background-color: var(--content-background-color); 295 + padding: 12px; 296 + margin-bottom: 15px; 297 + border: 1px solid var(--border-color); 298 + border-radius: 12px; 299 + transition: background-color 0.15s ease; 300 + } 301 + 302 + #accountContainer:hover { 303 + background-color: var(--hover-bg); 304 + } 305 + 306 + #accountName { 307 + margin-left: 12px; 308 + font-size: 0.95em; 309 + max-width: 80%; 310 + overflow: hidden; 311 + text-overflow: ellipsis; 312 + white-space: nowrap; 313 + font-weight: 500; 314 + } 315 + 316 + #avatar { 317 + width: 48px; 318 + height: 48px; 319 + margin: 0; 320 + object-fit: cover; 321 + border-radius: 50%; 322 + border: 2px solid white; 323 + box-shadow: 0 1px 3px rgba(0,0,0,0.1); 324 + } 325 + 326 + /* App.Svelte Layout */ 327 + #Content { 328 + display: flex; 329 + width: 100%; 330 + height: 100%; 331 + flex-direction: row; 332 + justify-content: space-between; 333 + align-items: center; 334 + background-color: var(--background-color); 335 + color: var(--text-color); 336 + gap: 24px; 337 + } 338 + 339 + #Feed { 340 + overflow-y: auto; 341 + width: 65%; 342 + height: 100vh; 343 + padding-right: 16px; 344 + align-self: flex-start; 345 + } 346 + 347 + #spacer { 348 + padding: 0; 349 + margin: 0; 350 + height: 10vh; 351 + width: 100%; 352 + } 353 + 354 + #Account { 355 + width: 35%; 356 + display: flex; 357 + flex-direction: column; 358 + border: 1px solid var(--border-color); 359 + background-color: var(--content-background-color); 360 + max-height: 80vh; 361 + padding: 24px; 362 + margin-left: 16px; 363 + border-radius: 12px; 364 + box-shadow: var(--card-shadow); 365 + } 366 + 367 + #accountsList { 368 + display: flex; 369 + flex-direction: column; 370 + overflow-y: auto; 371 + height: 100%; 372 + width: 100%; 373 + padding: 8px 0; 374 + margin: 0; 375 + } 376 + 377 + #Header { 378 + text-align: center; 379 + font-size: 1.8em; 380 + margin-bottom: 16px; 381 + font-weight: 700; 382 + background: linear-gradient(to right, var(--link-color), #8b5cf6); 383 + -webkit-background-clip: text; 384 + -webkit-text-fill-color: transparent; 385 + background-clip: text; 386 + } 387 + 388 + /* Mobile Styles */ 389 + @media screen and (max-width: 768px) { 390 + #Content { 391 + flex-direction: column; 392 + width: auto; 393 + padding: 12px; 394 + margin-top: 0; 395 + } 396 + 397 + #Account { 398 + width: calc(100% - 32px); 399 + padding: 16px; 400 + margin-bottom: 20px; 401 + margin-left: 0; 402 + margin-right: 0; 403 + height: auto; 404 + order: -1; 405 + } 406 + 407 + #Feed { 408 + width: 100%; 409 + margin: 0; 410 + padding: 0; 411 + overflow-y: visible; 412 + } 413 + 414 + #spacer { 415 + height: 5vh; 416 + } 417 + 418 + body { 419 + font-size: 16px; 420 + } 421 + 422 + #postHeader { 423 + padding: 10px; 424 + height: auto; 425 + min-height: 50px; 426 + } 427 + } 428 + 429 + /* Scrollbar Styles */ 430 + ::-webkit-scrollbar { 431 + width: 0px; 432 + background: transparent; 433 + padding: 0; 434 + margin: 0; 435 + } 436 + ::-webkit-scrollbar-thumb { 437 + background: transparent; 438 + border-radius: 0; 439 + } 440 + ::-webkit-scrollbar-track { 441 + background: transparent; 442 + border-radius: 0; 443 + } 444 + ::-webkit-scrollbar-corner { 445 + background: transparent; 446 + border-radius: 0; 447 + } 448 + ::-webkit-scrollbar-button { 449 + background: transparent; 450 + border-radius: 0; 451 + } 452 + 453 + * { 454 + scrollbar-width: none; 455 + scrollbar-color: transparent transparent; 456 + -ms-overflow-style: none; /* IE and Edge */ 457 + -webkit-overflow-scrolling: touch; 458 + -webkit-scrollbar: none; /* Safari */ 459 + } 460 + 461 + :root { 462 + /* Dark theme derived from provided OKLCH palette (applied via valid :root) */ 463 + color-scheme: dark; 464 + 465 + /* Base and content colors */ 466 + --color-base-100: oklch(25.33% 0.016 252.42); 467 + --color-base-200: oklch(23.26% 0.014 253.1); 468 + --color-base-300: oklch(21.15% 0.012 254.09); 469 + --color-base-content: oklch(97.807% 0.029 256.847); 470 + 471 + /* Brand and semantic colors */ 472 + --color-primary: oklch(58% 0.233 277.117); 473 + --color-primary-content: oklch(96% 0.018 272.314); 474 + --color-secondary: oklch(65% 0.241 354.308); 475 + --color-secondary-content: oklch(94% 0.028 342.258); 476 + --color-accent: oklch(77% 0.152 181.912); 477 + --color-accent-content: oklch(38% 0.063 188.416); 478 + --color-neutral: oklch(14% 0.005 285.823); 479 + --color-neutral-content: oklch(92% 0.004 286.32); 480 + --color-info: oklch(74% 0.16 232.661); 481 + --color-info-content: oklch(29% 0.066 243.157); 482 + --color-success: oklch(76% 0.177 163.223); 483 + --color-success-content: oklch(37% 0.077 168.94); 484 + --color-warning: oklch(82% 0.189 84.429); 485 + --color-warning-content: oklch(41% 0.112 45.904); 486 + --color-error: oklch(71% 0.194 13.428); 487 + --color-error-content: oklch(27% 0.105 12.094); 488 + 489 + /* Radii, sizes, borders */ 490 + --radius-selector: 0.5rem; 491 + --radius-field: 0.25rem; 492 + --radius-box: 0.5rem; 493 + --size-selector: 0.25rem; 494 + --size-field: 0.25rem; 495 + --border: 1px; 496 + --depth: 1; 497 + --noise: 0; 498 + 499 + /* Mappings for the rest of the CSS */ 500 + --background-color: var(--color-base-300); 501 + --header-background-color: var(--color-base-200); 502 + --content-background-color: var(--color-base-200); 503 + --text-color: var(--color-base-content); 504 + --text-secondary-color: color-mix(in oklab, var(--color-base-content) 70%, var(--color-base-100)); 505 + --border-color: var(--color-base-300); 506 + --link-color: var(--color-primary); 507 + --link-hover-color: var(--color-primary-content); 508 + --time-color: var(--color-secondary); 509 + --indicator-inactive-color: var(--color-base-300); 510 + --indicator-active-color: var(--color-primary); 511 + 512 + /* Subtle hover background for dark */ 513 + --button-hover: color-mix(in oklab, var(--color-base-200) 80%, var(--color-base-content)); 514 + }