+37
README.md
+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
+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
-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
+1
package.json
public/favicon.ico
public/favicon.ico
This is a binary file and will not be displayed.
public/moo.webp
public/moo.webp
This is a binary file and will not be displayed.
public/unknown.png
public/unknown.png
This is a binary file and will not be displayed.
+15
-8
src/App.svelte
+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
+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
+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
+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
+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
+
}