Compare commits

..

28 commits

Author SHA1 Message Date
614f2b4c30
Add links to the source code
All checks were successful
Deploy / Deploy (push) Successful in 39s
2025-04-26 14:46:30 +09:00
72ba577950 Proper post word wrapping (#3)
All checks were successful
Deploy / Deploy (push) Successful in 19s
Co-authored-by: ari <ariadna@omg.lol>
Reviewed-on: #3
2025-04-23 04:10:14 +00:00
ari
3d38e0f68c Update README.md
All checks were successful
Deploy / Deploy (push) Successful in 36s
2025-04-22 05:27:05 +00:00
5eca07724e Distance tweaks for hidden loads
All checks were successful
Deploy / Deploy (push) Successful in 28s
2025-04-22 03:53:05 +00:00
ari
cff9eed1a4 Dynamic post loading (#2)
All checks were successful
Deploy / Deploy (push) Successful in 33s
Dynamically load the posts so that you can scroll a chronologically sorted timeline infinitely

Reviewed-on: #2
Co-authored-by: ari <ariadna@omg.lol>
Co-committed-by: ari <ariadna@omg.lol>
2025-04-22 03:14:37 +00:00
26aff55dc2
Let's not do that
All checks were successful
Deploy / Deploy (push) Successful in 29s
2025-04-21 18:57:56 +09:00
bd7572dbd8
Let's fetch a bit more posts for now
All checks were successful
Deploy / Deploy (push) Successful in 28s
2025-04-21 18:56:56 +09:00
2db2ca4a05
Formatting, cleanup, misc style changes, more config options
All checks were successful
Deploy / Deploy (push) Successful in 28s
2025-04-21 16:20:43 +09:00
3af16a98e2
More typos in readme
All checks were successful
Deploy / Deploy (push) Successful in 27s
2025-04-21 15:58:31 +09:00
8cf19c6a77
Typos and proper links
All checks were successful
Deploy / Deploy (push) Successful in 30s
2025-04-21 15:55:34 +09:00
8a2ad07d8b
Draft of a proper readme and a license file
All checks were successful
Deploy / Deploy (push) Successful in 33s
2025-04-21 15:52:32 +09:00
23cd738d71
Remove unused icons
All checks were successful
Deploy / Deploy (push) Successful in 25s
2025-04-21 15:32:30 +09:00
b01b6b7f65 Cleanup and Maintenance part 1 (#1)
All checks were successful
Deploy / Deploy (push) Successful in 30s
Co-authored-by: ari <ariadna@omg.lol>
Reviewed-on: #1
2025-04-21 06:30:49 +00:00
7d850f0ab5
CI/CD
All checks were successful
Deploy / Deploy (push) Successful in 27s
2025-04-21 14:31:09 +09:00
d440f1c465
Trying a different approach for deploying the thing
All checks were successful
Deploy / Deploy (push) Successful in 27s
2025-04-21 14:25:56 +09:00
965753107e
I am so lost
Some checks failed
Deploy / Deploy (push) Failing after 33s
2025-04-20 21:14:27 +09:00
a2a7f8fcc7
Am I wrong?
Some checks failed
Deploy / Deploy (push) Failing after 7s
2025-04-20 21:12:10 +09:00
df18b3f97f
Will that work?
Some checks failed
Deploy / Deploy (push) Failing after 8s
2025-04-20 21:09:14 +09:00
4f85fe2ef6
Typo -_-
Some checks failed
Deploy / Deploy (push) Failing after 3s
2025-04-20 21:07:11 +09:00
5505612888
Still no idea
Some checks failed
Deploy / Deploy (push) Failing after 6s
2025-04-20 21:06:27 +09:00
3de4682c34
I am deno
Some checks failed
Deploy / Deploy (push) Failing after 4s
2025-04-20 21:02:43 +09:00
9ee49f9089
I have no idea what I am doing
Some checks failed
Deploy / Deploy (push) Failing after 13s
2025-04-20 20:56:13 +09:00
2a08708c69
Replace npm-install action with just "npm install"
Some checks failed
Deploy / Deploy (push) Failing after 27s
2025-04-20 20:54:21 +09:00
dcefd4f0cf
CI/CD testing branch
Some checks failed
Deploy / Deploy (push) Failing after 1m12s
2025-04-20 20:49:54 +09:00
2a33061ae4
CI/CD 2025-04-20 20:49:18 +09:00
686ef007c4
Mobile-friendly design 2025-04-20 20:44:56 +09:00
ebca274e5a
"Final-ish" design 2025-04-20 20:08:13 +09:00
037dea4e40
Style still WIP but much better now 2025-04-20 18:14:12 +09:00
17 changed files with 773 additions and 229 deletions

View file

@ -0,0 +1,58 @@
name: Deploy
on:
push:
branches:
- main
- astra/ci
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Setup Deno
uses: https://github.com/denoland/setup-deno@v2
- name: Install dependencies
run: deno install
- name: Build project
run: deno task build
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
cat > ~/.ssh/config << EOF
Host deploy
HostName ${{ vars.SERVER_HOST }}
User ${{ vars.SERVER_USER }}
IdentityFile ~/.ssh/id_ed25519
StrictHostKeyChecking accept-new
BatchMode yes
PasswordAuthentication no
PubkeyAuthentication yes
EOF
chmod 600 ~/.ssh/config
ssh-keyscan -H ${{ vars.SERVER_HOST }} >> ~/.ssh/known_hosts
echo "Deploying to ${{ vars.SERVER_HOST }} as ${{ vars.SERVER_USER }} to /var/www/pds/${{ github.ref_name }}"
- name: Debug SSH Connection
run: ssh -v deploy echo "SSH Connection Successful"
- name: Create folder if not exists
run: ssh deploy "mkdir -p /var/www/pds/${{ github.ref_name }}"
- name: Deploy via SCP
run: scp -r ./dist/* deploy:/var/www/pds/${{ github.ref_name }}

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
# MIT License
Copyright (c) 2025 Witchcraft Systems
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,3 +1,62 @@
# pds-dash
Frontend with stats for your ATProto PDS
a frontend dashboard with stats for your ATProto PDS.
## setup
### prerequisites
- [deno](https://deno.com/manual/getting_started/installation)
### installing
clone the repo, install dependencies using deno:
```sh
deno install
```
### development server
local develompent server with hot reloading:
```sh
deno task dev
```
### building
to build the optimized bundle run:
```sh
deno task build
```
the output will be in the `dist/` directory.
## deploying
we use our own CI/CD workflow at [`.forgejo/workflows/deploy.yaml`](.forgejo/workflows/deploy.yaml), but it boils down to building the project bundle and deploying it to a web server. it'll probably make more sense to host it on the same domain as your PDS, but it doesn't affect anything if you host it somewhere else.
## configuring
[`config.ts`](config.ts) is the main configuration file, you can find more information in the file itself.
## theming
the colors are designated in [`src/app.css`](src/app.css) as variables, go crazy with them
the rest is done by editing the css files and style tags directly, good luck
relevant files:
- [`src/App.svelte`](src/App.svelte)
- [`src/app.css`](src/app.css)
- [`src/lib/AccountComponent.svelte`](src/lib/AccountComponent.svelte)
- [`src/lib/PostComponent.svelte`](src/lib/PostComponent.svelte)
the favicon is located at [`public/favicon.ico`](public/favicon.ico)
## license
MIT

View file

@ -2,15 +2,36 @@
* Configuration module for the PDS Dashboard
*/
export class Config {
/**
* The base URL of the PDS (Personal Data Server)
* @default "https://pds.witchcraft.systems"
*/
static readonly PDS_URL: string = "https://pds.witchcraft.systems";
/**
* The base URL of the PDS (Personal Data Server)
* @default "https://pds.witchcraft.systems"
*/
static readonly PDS_URL: string = "https://pds.witchcraft.systems";
/**
* The base URL of the frontend service for linking to replies
* @default "https://deer.social"
*/
static readonly FRONTEND_URL: string = "https://deer.social";
/**
* The base URL of the frontend service for linking to replies/quotes/accounts etc.
* @default "https://deer.social"
*/
static readonly FRONTEND_URL: string = "https://deer.social";
/**
* Maximum number of posts to fetch from the PDS per request
* Should be around 20 for about 10 users on the pds
* The more users you have, the lower the number should be
* since sorting is slow and is done on the frontend
* @default 20
*/
static readonly MAX_POSTS: number = 20;
/**
* Footer text for the dashboard, you probably want to change this
*/
static readonly FOOTER_TEXT: string =
"Astrally projected from <a href='https://witchcraft.systems' target='_blank'>witchcraft.systems</a><br><br><a href='https://git.witchcraft.systems/scientific-witchery/pds-dash' target='_blank'>Source</a> (<a href='https://github.com/witchcraft-systems/pds-dash/' target='_blank'>github mirror</a>)";
/**
* Whether to show the posts that are in the future
* @default false
*/
static readonly SHOW_FUTURE_POSTS: boolean = false;
}

10
deno.lock generated
View file

@ -6,7 +6,9 @@
"npm:@atcute/identity-resolver@~0.1.2": "0.1.2_@atcute+identity@0.1.3",
"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",
"npm:@tsconfig/svelte@^5.0.4": "5.0.4",
"npm:moment@^2.30.1": "2.30.1",
"npm:svelte-check@^4.1.5": "4.1.6_svelte@5.28.1__acorn@8.14.1_typescript@5.7.3",
"npm:svelte-infinite-loading@^1.4.0": "1.4.0",
"npm:svelte@^5.23.1": "5.28.1_acorn@8.14.1",
"npm:typescript@~5.7.2": "5.7.3",
"npm:vite@^6.3.1": "6.3.2_picomatch@4.0.2"
@ -337,6 +339,9 @@
"@jridgewell/sourcemap-codec"
]
},
"moment@2.30.1": {
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="
},
"mri@1.2.0": {
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
},
@ -411,6 +416,9 @@
"typescript"
]
},
"svelte-infinite-loading@1.4.0": {
"integrity": "sha512-Jo+f/yr/HmZQuIiiKKzAHVFXdAUWHW2RBbrcQTil8JVk1sCm/riy7KTJVzjBgQvHasrFQYKF84zvtc9/Y4lFYg=="
},
"svelte@5.28.1_acorn@8.14.1": {
"integrity": "sha512-iOa9WmfNG95lSOSJdMhdjJ4Afok7IRAQYXpbnxhd5EINnXseG0GVa9j6WPght4eX78XfFez45Fi+uRglGKPV/Q==",
"dependencies": [
@ -470,7 +478,9 @@
"npm:@atcute/identity-resolver@~0.1.2",
"npm:@sveltejs/vite-plugin-svelte@^5.0.3",
"npm:@tsconfig/svelte@^5.0.4",
"npm:moment@^2.30.1",
"npm:svelte-check@^4.1.5",
"npm:svelte-infinite-loading@^1.4.0",
"npm:svelte@^5.23.1",
"npm:typescript@~5.7.2",
"npm:vite@^6.3.1"

View file

@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />

View file

@ -12,7 +12,9 @@
"dependencies": {
"@atcute/bluesky": "^2.0.2",
"@atcute/client": "^3.0.1",
"@atcute/identity-resolver": "^0.1.2"
"@atcute/identity-resolver": "^0.1.2",
"moment": "^2.30.1",
"svelte-infinite-loading": "^1.4.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,40 +1,71 @@
<script lang="ts">
import PostComponent from "./lib/PostComponent.svelte";
import AccountComponent from "./lib/AccountComponent.svelte";
import { fetchAllPosts, Post, getAllMetadataFromPds } from "./lib/pdsfetch";
const postsPromise = fetchAllPosts();
import InfiniteLoading from "svelte-infinite-loading";
import { getNextPosts, Post, getAllMetadataFromPds } from "./lib/pdsfetch";
import { Config } from "../config";
const accountsPromise = getAllMetadataFromPds();
import { onMount } from "svelte";
let posts: Post[] = [];
onMount(() => {
// Fetch initial posts
getNextPosts().then((initialPosts) => {
posts = initialPosts;
});
});
// Infinite loading function
const onInfinite = ({
detail: { loaded, complete },
}: {
detail: { loaded: () => void; complete: () => void };
}) => {
getNextPosts().then((newPosts) => {
console.log("Loading next posts...");
if (newPosts.length > 0) {
posts = [...posts, ...newPosts];
loaded();
} else {
complete();
}
});
};
</script>
<main>
<div id="Content">
{#await accountsPromise}
<p>Loading...</p>
{:then accountsData}
<div id="Account">
<h1 id="Header">ATProto PDS</h1>
<p>Home to {accountsData.length} accounts</p>
{#each accountsData as accountObject}
<AccountComponent account={accountObject} />
{/each}
</div>
{:catch error}
<p>Error: {error.message}</p>
{/await}
{#await accountsPromise}
<p>Loading...</p>
{:then accountsData}
<div id="Account">
<h1 id="Header">ATProto PDS</h1>
<p>Home to {accountsData.length} accounts</p>
<div id="accountsList">
{#each accountsData as accountObject}
<AccountComponent account={accountObject} />
{/each}
</div>
<p>{@html Config.FOOTER_TEXT}</p>
</div>
{:catch error}
<p>Error: {error.message}</p>
{/await}
{#await postsPromise}
<p>Loading...</p>
{:then postsData}
<div id="Feed">
{#each postsData as postObject}
<div id="spacer"></div>
{#each posts as postObject}
<PostComponent post={postObject as Post} />
{/each}
<InfiniteLoading on:infinite={onInfinite} distance={3000} />
<div id="spacer"></div>
</div>
{/await}
</div>
</main>
<style>
/* desktop style */
#Content {
display: flex;
/* split the screen in half, left for accounts, right for posts */
@ -43,27 +74,80 @@
flex-direction: row;
justify-content: space-between;
align-items: center;
background-color: #12082b;
color: #ffffff;
background-color: var(--background-color);
color: var(--text-color);
}
#Feed {
width: 65%;
height: 80vh;
height: 100vh;
overflow-y: scroll;
padding: 20px;
padding-bottom: 0;
padding-top: 0;
margin-top: 0;
margin-bottom: 0;
}
#spacer {
padding: 0;
margin: 0;
height: 10vh;
width: 100%;
}
#Account {
width: 35%;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
background-color: var(--content-background-color);
height: 80vh;
overflow-y: scroll;
padding: 20px;
background-color: #070311;
border-radius: 10px;
margin-left: 20px;
}
#accountsList {
display: flex;
flex-direction: column;
overflow-y: scroll;
height: 100%;
width: 100%;
padding: 0px;
margin: 0px;
}
#Header {
text-align: center;
font-size: 2em;
margin-bottom: 20px;
}
/* mobile style */
@media screen and (max-width: 600px) {
#Content {
flex-direction: column;
width: auto;
padding-left: 0px;
padding-right: 0px;
margin-top: 5%;
}
#Account {
width: auto;
padding-left: 5%;
padding-right: 5%;
margin-bottom: 20px;
margin-left: 5%;
margin-right: 5%;
height: auto;
}
#Feed {
width: 95%;
margin: 0px;
margin-left: 10%;
margin-right: 10%;
padding: 0px;
height: auto;
}
#spacer {
height: 0;
}
}
</style>

View file

@ -1,15 +1,44 @@
@font-face {
font-family: 'ProggyClean';
font-family: "ProggyClean";
src: url(https://witchcraft.systems/ProggyCleanNerdFont-Regular.ttf);
}
:root {
--link-color: #646cff;
--link-hover-color: #535bf2;
--background-color: #12082b;
--header-background-color: #1f1145;
--content-background-color: #0d0620;
--text-color: white;
--border-color: #8054f0;
--indicator-inactive-color: #4a4a4a;
--indicator-active-color: #8054f0;
}
::-webkit-scrollbar {
width: 0px;
background: transparent;
padding: 0;
margin: 0;
}
::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 0;
}
::-webkit-scrollbar-track {
background: transparent;
border-radius: 0;
}
::-webkit-scrollbar-corner {
background: transparent;
border-radius: 0;
}
::-webkit-scrollbar-button {
background: transparent;
border-radius: 0;
}
* {
scrollbar-width: thin;
scrollbar-width: none;
scrollbar-color: transparent transparent;
-ms-overflow-style: none; /* IE and Edge */
-webkit-overflow-scrolling: touch;
@ -18,11 +47,12 @@
a {
font-weight: 500;
color: #646cff;
color: var(--link-color);
text-decoration: inherit;
}
a:hover {
color: #535bf2;
color: var(--link-hover-color);
text-decoration: underline;
}
body {
@ -31,11 +61,11 @@ body {
place-items: center;
min-width: 320px;
min-height: 100vh;
background-color: #12082b;
font-family: 'ProggyClean', monospace;
background-color: var(--background-color);
font-family: "ProggyClean", monospace;
font-size: 24px;
color: white;
border-color: #8054f0;
color: var(--text-color);
border-color: var(--border-color);
}
h1 {
@ -45,9 +75,9 @@ h1 {
#app {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
margin: 0;
padding: 0;
margin-left: auto;
margin-right: auto;
text-align: center;
}

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -1,49 +1,48 @@
<script lang="ts">
import type { AccountMetadata } from "./pdsfetch";
const { account }: { account: AccountMetadata } = $props();
import { Config } from "../../config";
import type { AccountMetadata } from "./pdsfetch";
const { account }: { account: AccountMetadata } = $props();
import { Config } from "../../config";
</script>
<a id="link" href="{Config.FRONTEND_URL}/profile/{account.did}">
<div id="accountContainer">
{#if account.avatarCid}
<img
id="avatar"
alt="avatar of {account.displayName}"
src="{Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did={account.did}&cid={account.avatarCid}"
/>
{/if}
<div id="accountName">
{account.displayName || account.handle || account.did}
</div>
<div id="accountContainer">
{#if account.avatarCid}
<img
id="avatar"
alt="avatar of {account.displayName}"
src="{Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did={account.did}&cid={account.avatarCid}"
/>
{/if}
<div id="accountName">
{account.displayName || account.handle || account.did}
</div>
</div>
</a>
<style>
#accountContainer {
display: flex;
text-align: start;
align-items: center;
background-color: #0d0620;
padding: 4%;
margin: 10px;
#accountContainer {
display: flex;
text-align: start;
align-items: center;
background-color: var(--background-color);
padding: 0px;
margin-bottom: 15px;
border: 1px solid var(--border-color);
}
#accountName {
margin-left: 10px;
font-size: 0.9em;
/* round corners */
border-radius: 10px;
}
#accountName {
margin-left: 10px;
font-size: 0.9em;
/* replace overflow with ellipsis */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 80%;
}
#avatar {
width: 50px;
height: 50px;
border-radius: 50%;
}
/* replace overflow with ellipsis */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 80%;
}
#avatar {
width: 50px;
height: 50px;
margin: 0px;
border-right: var(--border-color) 1px solid;
}
</style>

View file

@ -1,7 +1,59 @@
<script lang="ts">
import { Post } from "./pdsfetch";
import { Config } from "../../config";
import { onMount } from "svelte";
import moment from "moment";
let { post }: { post: Post } = $props();
// State for image carousel
let currentImageIndex = $state(0);
// Functions to navigate carousel
function nextImage() {
if (post.imagesCid && currentImageIndex < post.imagesCid.length - 1) {
currentImageIndex++;
}
}
function prevImage() {
if (currentImageIndex > 0) {
currentImageIndex--;
}
}
// Function to preload an image
function preloadImage(index: number): void {
if (!post.imagesCid || index < 0 || index >= post.imagesCid.length) return;
const img = new Image();
img.src = `${Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did=${post.authorDid}&cid=${post.imagesCid[index]}`;
}
// Preload adjacent images when current index changes
$effect(() => {
if (post.imagesCid && post.imagesCid.length > 1) {
// Preload next image if available
if (currentImageIndex < post.imagesCid.length - 1) {
preloadImage(currentImageIndex + 1);
}
// Preload previous image if available
if (currentImageIndex > 0) {
preloadImage(currentImageIndex - 1);
}
}
});
// Initial preload of images
onMount(() => {
if (post.imagesCid && post.imagesCid.length > 1) {
// Preload the next image if it exists
if (post.imagesCid.length > 1) {
preloadImage(1);
}
}
});
</script>
<div id="postContainer">
@ -14,110 +66,229 @@
/>
{/if}
<div id="headerText">
<a href="{Config.FRONTEND_URL}/profile/{post.authorDid}"
>{post.displayName} ( {post.authorHandle} )</a
>
|
<a href="{Config.FRONTEND_URL}/profile/{post.authorDid}/post/{post.cid}"
>{post.timenotstamp}</a
<a id="displayName" href="{Config.FRONTEND_URL}/profile/{post.authorDid}"
>{post.displayName}</a
>
<p id="handle">
<a href="{Config.FRONTEND_URL}/profile/{post.authorHandle}"
>{post.authorHandle}</a
>
<a
id="postLink"
href="{Config.FRONTEND_URL}/profile/{post.authorDid}/post/{post.recordName}"
>{moment(post.timenotstamp).isBefore(moment().subtract(1, "month"))
? moment(post.timenotstamp).format("MMM D, YYYY")
: moment(post.timenotstamp).fromNow()}</a
>
</p>
</div>
</div>
<div id="postContent">
{#if post.replyingUri}
<a
id="replyingText"
href="https://deer.social/profile/{post.replyingUri.repo}/post/{post
href="{Config.FRONTEND_URL}/profile/{post.replyingUri.repo}/post/{post
.replyingUri.rkey}">replying to {post.replyingUri.repo}</a
>
{/if}
<p>{post.text}</p>
{#if post.quotingUri}
<a
id="quotingText"
href="https://deer.social/profile/{post.quotingUri.repo}/post/{post
href="{Config.FRONTEND_URL}/profile/{post.quotingUri.repo}/post/{post
.quotingUri.rkey}">quoting {post.quotingUri.repo}</a
>
{/if}
{#if post.imagesCid}
<div id="imagesContainer">
{#each post.imagesCid as imageLink}
<img
id="embedImages"
alt="Post Image"
src="{Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did={post.authorDid}&cid={imageLink}"
/>
{/each}
<div id="postText">{post.text}</div>
{#if post.imagesCid && post.imagesCid.length > 0}
<div id="carouselContainer">
<img
id="embedImages"
alt="Post Image {currentImageIndex + 1} of {post.imagesCid.length}"
src="{Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did={post.authorDid}&cid={post
.imagesCid[currentImageIndex]}"
/>
{#if post.imagesCid.length > 1}
<div id="carouselControls">
<button
id="prevBtn"
onclick={prevImage}
disabled={currentImageIndex === 0}>←</button
>
<div id="carouselIndicators">
{#each post.imagesCid as _, i}
<div
class="indicator {i === currentImageIndex ? 'active' : ''}"
></div>
{/each}
</div>
<button
id="nextBtn"
onclick={nextImage}
disabled={currentImageIndex === post.imagesCid.length - 1}
>→</button
>
</div>
{/if}
</div>
{/if}
{#if post.videosLinkCid}
<!-- svelte-ignore a11y_media_has_caption -->
<video
id="embedVideo"
src="{Config.PDS_URL}/xrpc/com.atproto.sync.getBlob?did={post.authorDid}&cid={post.videosLinkCid}"
/>
controls
></video>
{/if}
</div>
</div>
<style>
a:hover {
text-decoration: underline;
}
#postContainer {
display: flex;
flex-direction: column;
border: 1px solid #8054f0;
background-color: black;
margin-bottom: -1px;
border: 1px solid var(--border-color);
background-color: var(--background-color);
margin-bottom: 15px;
overflow-wrap: break-word;
}
#postHeader {
display: flex;
flex-direction: row;
align-items: center;
justify-content: start;
background-color: #1f1145;
background-color: var(--header-background-color);
padding: 0px 0px;
height: fit-content;
border-bottom: 1px solid #8054f0;
border-bottom: 1px solid var(--border-color);
font-weight: bold;
overflow-wrap: break-word;
height: 60px;
}
#displayName {
color: var(--text-color);
font-size: 1.2em;
padding: 0;
margin: 0;
}
#handle {
color: var(--border-color);
font-size: 0.8em;
padding: 0;
margin: 0;
}
#postLink {
color: var(--border-color);
font-size: 0.8em;
padding: 0;
margin: 0;
}
#postContent {
display: flex;
text-align: start;
flex-direction: column;
padding: 10px;
background-color: #0d0620;
color: white;
background-color: var(--content-background-color);
color: var(--text-color);
overflow-wrap: break-word;
white-space: pre-line;
}
#replyingText {
font-size: 0.7em;
color: white;
margin: 0;
margin-bottom: 10px;
padding: 0;
padding-bottom: 5px;
}
#quotingText {
font-size: 0.7em;
margin: 0;
padding: 0;
padding-bottom: 5px;
}
#postText {
margin: 0;
padding: 0;
overflow-wrap: break-word;
word-wrap: normal;
word-break: break-word;
hyphens: none;
}
#headerText {
margin-left: 10px;
font-size: 0.9em;
text-align: start;
overflow-wrap: break-word;
overflow: hidden;
}
#avatar {
width: 50px;
height: 50px;
height: 100%;
margin: 0px;
margin-left: 0px;
border-right: #8054f0 1px solid;
border-right: var(--border-color) 1px solid;
}
#embedImages {
width: 50%;
height: 50%;
margin-top: 0px;
margin-bottom: -5px;
#carouselContainer {
position: relative;
width: 100%;
margin-top: 10px;
display: flex;
flex-direction: column;
align-items: center;
}
#carouselControls {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
max-width: 500px;
margin-top: 5px;
}
#carouselIndicators {
display: flex;
gap: 5px;
}
.indicator {
width: 8px;
height: 8px;
background-color: var(--indicator-inactive-color);
}
.indicator.active {
background-color: var(--indicator-active-color);
}
#prevBtn,
#nextBtn {
background-color: rgba(31, 17, 69, 0.7);
color: var(--text-color);
border: 1px solid var(--border-color);
width: 30px;
height: 30px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
#prevBtn:disabled,
#nextBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#embedVideo {
width: 50%;
height: 50%;
width: 100%;
max-width: 500px;
margin-top: 10px;
align-self: center;
}
#embedImages {
min-width: min(100%, 500px);
max-width: min(100%, 500px);
max-height: 500px;
object-fit: contain;
margin: 0;
}
</style>

View file

@ -18,11 +18,15 @@ import { Config } from "../../config";
// import { AppBskyActorDefs } from "@atcute/client/lexicons";
interface AccountMetadata {
did: string;
did: At.Did;
displayName: string;
handle: string;
avatarCid: string | null;
currentCursor?: string;
}
let accountsMetadata: AccountMetadata[] = [];
interface atUriObject {
repo: string;
collection: string;
@ -32,6 +36,7 @@ class Post {
authorDid: string;
authorAvatarCid: string | null;
postCid: string;
recordName: string;
authorHandle: string;
displayName: string;
text: string;
@ -47,6 +52,7 @@ class Post {
account: AccountMetadata,
) {
this.postCid = record.cid;
this.recordName = processAtUri(record.uri).rkey;
this.authorDid = account.did;
this.authorAvatarCid = account.avatarCid;
this.authorHandle = account.handle;
@ -65,8 +71,8 @@ class Post {
this.videosLinkCid = null;
switch (post.embed?.$type) {
case "app.bsky.embed.images":
this.imagesCid = post.embed.images.map((imageRecord: any) =>
imageRecord.image.ref.$link
this.imagesCid = post.embed.images.map(
(imageRecord: any) => imageRecord.image.ref.$link,
);
break;
case "app.bsky.embed.video":
@ -79,8 +85,8 @@ class Post {
this.quotingUri = processAtUri(post.embed.record.record.uri);
switch (post.embed.media.$type) {
case "app.bsky.embed.images":
this.imagesCid = post.embed.media.images.map((imageRecord) =>
imageRecord.image.ref.$link
this.imagesCid = post.embed.media.images.map(
(imageRecord) => imageRecord.image.ref.$link,
);
break;
@ -109,77 +115,50 @@ const rpc = new XRPC({
}),
});
const getDidsFromPDS = async () => {
const getDidsFromPDS = async (): Promise<At.Did[]> => {
const { data } = await rpc.get("com.atproto.sync.listRepos", {
params: {},
});
return data.repos.map((repo: any) => (repo.did));
return data.repos.map((repo: any) => repo.did) as At.Did[];
};
const getAccountMetadata = async (did: `did:${string}:${string}`) => {
const getAccountMetadata = async (
did: `did:${string}:${string}`,
) => {
// gonna assume self exists in the app.bsky.actor.profile
try {
const { data } = await rpc.get("com.atproto.repo.getRecord", {
params: {
repo: did,
collection: "app.bsky.actor.profile",
rkey: "self",
},
});
const value = data.value as AppBskyActorProfile.Record;
const handle = await blueskyHandleFromDid(did);
const account: AccountMetadata = {
did: did,
handle: handle,
displayName: value.displayName || "",
avatarCid: null,
};
if (value.avatar) {
account.avatarCid = value.avatar.ref["$link"];
}
return account;
}
catch (e) {
console.error(`Error fetching metadata for ${did}:`, e);
return {
did: "error",
displayName: "",
const { data } = await rpc.get("com.atproto.repo.getRecord", {
params: {
repo: did,
collection: "app.bsky.actor.profile",
rkey: "self",
},
});
const value = data.value as AppBskyActorProfile.Record;
const handle = await blueskyHandleFromDid(did);
const account: AccountMetadata = {
did: did,
handle: handle,
displayName: value.displayName || "",
avatarCid: null,
};
if (value.avatar) {
account.avatarCid = value.avatar.ref["$link"];
}
return account;
} catch (e) {
console.error(`Error fetching metadata for ${did}:`, e);
return null;
}
};
const getAllMetadataFromPds = async () => {
const getAllMetadataFromPds = async (): Promise<AccountMetadata[]> => {
const dids = await getDidsFromPDS();
const metadata = await Promise.all(
dids.map(async (repo: `did:${string}:${string}`) => {
return await getAccountMetadata(repo);
}),
);
return metadata.filter(account => account.did !== "error");
};
const fetchPosts = async (did: string) => {
try {
const { data } = await rpc.get("com.atproto.repo.listRecords", {
params: {
repo: did as At.Identifier,
collection: "app.bsky.feed.post",
limit: 5,
},
});
return {
records: data.records as ComAtprotoRepoListRecords.Record[],
did: did,
error: false
};
} catch (e) {
console.error(`Error fetching posts for ${did}:`, e);
return {
records: [],
did: did,
error: true
};
}
return metadata.filter((account) => account !== null) as AccountMetadata[];
};
const identityResolve = async (did: At.Did) => {
@ -215,34 +194,146 @@ const blueskyHandleFromDid = async (did: At.Did) => {
}
};
const fetchAllPosts = async () => {
const users: AccountMetadata[] = await getAllMetadataFromPds();
const postRecords = await Promise.all(
users.map(async (metadata: AccountMetadata) =>
await fetchPosts(metadata.did)
),
);
const validPostRecords = postRecords.filter(record => !record.error);
const posts: Post[] = validPostRecords.flatMap((userFetch) =>
userFetch.records.map((record) => {
const user = users.find((user: AccountMetadata) =>
user.did == userFetch.did
);
if (!user) {
throw new Error(`User with DID ${userFetch.did} not found`);
interface PostsAcc {
posts: ComAtprotoRepoListRecords.Record[];
account: AccountMetadata;
}
const getCutoffDate = (postAccounts: PostsAcc[]) => {
const now = Date.now();
let cutoffDate: Date | null = null;
postAccounts.forEach((postAcc) => {
const latestPost = new Date(
(postAcc.posts[postAcc.posts.length - 1].value as AppBskyFeedPost.Record)
.createdAt,
);
if (!cutoffDate) {
cutoffDate = latestPost;
} else {
if (latestPost > cutoffDate) {
cutoffDate = latestPost;
}
return new Post(record, user);
})
);
posts.sort((a, b) => b.timestamp - a.timestamp);
return posts;
}
});
if (cutoffDate) {
return cutoffDate;
} else {
return new Date(now);
}
};
const testApiCall = async () => {
const { data } = await rpc.get("com.atproto.sync.listRepos", {
params: {},
const filterPostsByDate = (posts: PostsAcc[], cutoffDate: Date) => {
// filter posts for each account that are older than the cutoff date and save the cursor of the last post included
const filteredPosts: PostsAcc[] = posts.map((postAcc) => {
const filtered = postAcc.posts.filter((post) => {
const postDate = new Date(
(post.value as AppBskyFeedPost.Record).createdAt,
);
return postDate >= cutoffDate;
});
if (filtered.length > 0) {
postAcc.account.currentCursor = processAtUri(filtered[filtered.length - 1].uri).rkey;
}
return {
posts: filtered,
account: postAcc.account,
};
});
console.log(data);
return filteredPosts;
};
export { fetchAllPosts, getAllMetadataFromPds, Post };
// nightmare function. However it works so I am not touching it
const getNextPosts = async () => {
if (!accountsMetadata.length) {
accountsMetadata = await getAllMetadataFromPds();
}
const postsAcc: PostsAcc[] = await Promise.all(
accountsMetadata.map(async (account) => {
const posts = await fetchPostsForUser(
account.did,
account.currentCursor || null,
);
if (posts) {
return {
posts: posts,
account: account,
};
} else {
return {
posts: [],
account: account,
};
}
}),
);
const recordsFiltered = postsAcc.filter((postAcc) =>
postAcc.posts.length > 0
);
const cutoffDate = getCutoffDate(recordsFiltered);
const recordsCutoff = filterPostsByDate(recordsFiltered, cutoffDate);
// update the accountMetadata with the new cursor
accountsMetadata = accountsMetadata.map((account) => {
const postAcc = recordsCutoff.find(
(postAcc) => postAcc.account.did == account.did,
);
if (postAcc) {
account.currentCursor = postAcc.account.currentCursor;
}
return account;
}
);
// throw the records in a big single array
let records = recordsCutoff.flatMap((postAcc) => postAcc.posts);
// sort the records by timestamp
records = records.sort((a, b) => {
const aDate = new Date(
(a.value as AppBskyFeedPost.Record).createdAt,
).getTime();
const bDate = new Date(
(b.value as AppBskyFeedPost.Record).createdAt,
).getTime();
return bDate - aDate;
});
// filter out posts that are in the future
if (!Config.SHOW_FUTURE_POSTS) {
const now = Date.now();
records = records.filter((post) => {
const postDate = new Date(
(post.value as AppBskyFeedPost.Record).createdAt,
).getTime();
return postDate <= now;
});
}
const newPosts = records.map((record) => {
const account = accountsMetadata.find(
(account) => account.did == processAtUri(record.uri).repo,
);
if (!account) {
throw new Error(
`Account with DID ${processAtUri(record.uri).repo} not found`,
);
}
return new Post(record, account);
});
return newPosts;
};
const fetchPostsForUser = async (did: At.Did, cursor: string | null) => {
try {
const { data } = await rpc.get("com.atproto.repo.listRecords", {
params: {
repo: did as At.Identifier,
collection: "app.bsky.feed.post",
limit: Config.MAX_POSTS,
cursor: cursor || undefined,
},
});
return data.records as ComAtprotoRepoListRecords.Record[];
} catch (e) {
console.error(`Error fetching posts for ${did}:`, e);
return null;
}
};
export { getAllMetadataFromPds, getNextPosts, Post };
export type { AccountMetadata };

View file

@ -1,9 +1,9 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
import { mount } from "svelte";
import "./app.css";
import App from "./App.svelte";
const app = mount(App, {
target: document.getElementById('app')!,
})
target: document.getElementById("app")!,
});
export default app
export default app;

View file

@ -1,7 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}
};

View file

@ -1,7 +1,7 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
})
});