From 76e50f1ff0c15b950e0d2ce9b4ab08c088617c2c Mon Sep 17 00:00:00 2001 From: afterlifepro Date: Thu, 4 Dec 2025 23:16:28 +0000 Subject: [PATCH] rework landing to be a lil more robust + remove the text gradient bc workin emojis is better lmao --- compose.yaml | 2 + landing/Dockerfile | 3 +- landing/css.css | 66 ------------------- landing/deno.json | 5 ++ landing/deno.lock | 16 +++++ landing/heading.txt | 1 + landing/index.ts | 156 ++++++++++++++++++++++++++++++++++++++++++++ landing/landing.ts | 127 ------------------------------------ landing/schemas.ts | 49 ++++++++++++++ landing/styles.css | 30 +++++++++ 10 files changed, 261 insertions(+), 194 deletions(-) delete mode 100644 landing/css.css create mode 100644 landing/deno.json create mode 100644 landing/deno.lock create mode 100644 landing/index.ts delete mode 100644 landing/landing.ts create mode 100644 landing/schemas.ts create mode 100644 landing/styles.css diff --git a/compose.yaml b/compose.yaml index fef32d5..9d6fb4b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,6 +10,8 @@ services: landing: build: ./landing + environment: + - PORT=8000 restart: unless-stopped caddy: diff --git a/landing/Dockerfile b/landing/Dockerfile index 7f31ee8..acd730b 100644 --- a/landing/Dockerfile +++ b/landing/Dockerfile @@ -2,6 +2,7 @@ FROM denoland/deno:latest WORKDIR /app COPY ./ /app +RUN deno install RUN deno bundle --unstable-raw-imports --output bundle.js /app/landing.ts -CMD deno --allow-net bundle.js \ No newline at end of file +CMD deno --allow-net --unstable-temporal bundle.js \ No newline at end of file diff --git a/landing/css.css b/landing/css.css deleted file mode 100644 index d91e1b2..0000000 --- a/landing/css.css +++ /dev/null @@ -1,66 +0,0 @@ -@property --gradient-offset { - syntax: ""; - inherits: false; - initial-value: 0px; -} - -@keyframes scroll { - from { - --gradient-offset: 0px; - } - - to { - --gradient-offset: var(--gradient-gap); - } -} - -html { - background: repeating-linear-gradient(#fff1, #0000 5px, #fff1 10px), black; - min-height: 100%; - font-family: "Monaspace neon var", monospace; - font-weight: bold; -} - -html, -body { - margin: 0; -} - -pre { - /* config */ - --light: lime; - --dark: green; - --gradient-gap: 10px; - --gradient-size: 5px; - --gradient-offset: 0px; - --gradient-max: calc(var(--gradient-gap) * 2 + var(--gradient-size)); - - /* override some resource://content-accessible/plaintext.css styles in ff */ - white-space: pre !important; - width: max-content; - padding: 1em; - margin-block: 0; - padding-block-start: 0; - - text-shadow: 0 0 5px lime; - color: lime; - @supports (background-clip: text) { - color: transparent; - background: repeating-linear-gradient( - var(--light), - var(--light) calc(var(--gradient-gap) - var(--gradient-offset)), - var(--dark) calc(var(--gradient-gap) - var(--gradient-offset)), - var(--dark) - calc( - var(--gradient-gap) - var(--gradient-offset) + var(--gradient-size) - ), - var(--light) - calc( - var(--gradient-gap) - var(--gradient-offset) + var(--gradient-size) - ), - var(--light) var(--gradient-max) - ) - text; - animation: 10s infinite scroll linear; - } -} diff --git a/landing/deno.json b/landing/deno.json new file mode 100644 index 0000000..ac58819 --- /dev/null +++ b/landing/deno.json @@ -0,0 +1,5 @@ +{ + "imports": { + "zod": "npm:zod@^4.1.13" + } +} diff --git a/landing/deno.lock b/landing/deno.lock new file mode 100644 index 0000000..5b5cc7a --- /dev/null +++ b/landing/deno.lock @@ -0,0 +1,16 @@ +{ + "version": "5", + "specifiers": { + "npm:zod@^4.1.13": "4.1.13" + }, + "npm": { + "zod@4.1.13": { + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==" + } + }, + "workspace": { + "dependencies": [ + "npm:zod@^4.1.13" + ] + } +} diff --git a/landing/heading.txt b/landing/heading.txt index 01a0588..e985614 100644 --- a/landing/heading.txt +++ b/landing/heading.txt @@ -13,3 +13,4 @@ Runs off a pi on my desk :3 Setup Guide: https://vielle.dev/blog/pds-on-a-pi/ Protocol: https://atproto.com/ .dong file: https://dongs.zip/ + health: \ No newline at end of file diff --git a/landing/index.ts b/landing/index.ts new file mode 100644 index 0000000..841dd1b --- /dev/null +++ b/landing/index.ts @@ -0,0 +1,156 @@ +import css from "./styles.css" with { type: "text" }; +import heading from "./heading.txt" with { type: "text" }; +import { + didDocSchema, + getRecordSchema, + listRecordsSchema, + listReposSchema, + profileSelfSchema, + statusphereSchema, + tealFmStatusSchema, +} from "./schemas.ts"; + +const PORT = Number(Deno.env.get("PORT")); +const PDS = Deno.env.get("PDS") ?? "http://pi:8000"; + +async function renderUser(did: string): Promise { + const handle = fetch( + did.startsWith("did:plc") + ? "https://plc.directory/" + did + : `https://${did.replace("did:web:", "")}/.well-known/did.json` + ) + .then((res) => res.json()) + .then((res) => didDocSchema.safeParse(res).data) + .then( + (doc) => + doc.alsoKnownAs + .filter((x) => x.startsWith("at://"))[0] + ?.replace("at://", "") ?? "invalid.handle" + ); + + const displayName = fetch( + `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self` + ) + .then((res) => res.json()) + .then((data) => getRecordSchema(profileSelfSchema).safeParse(data).data) + .then((data) => (data ? data.value.displayName : undefined)); + + const statusphere = fetch( + `${PDS}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=xyz.statusphere.status` + ) + .then((res) => res.json()) + .then((data) => listRecordsSchema(statusphereSchema).safeParse(data).data) + .then((data) => + data && data.records.length > 0 ? data.records[0].value.status : undefined + ); + + const nowPlaying = fetch( + `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=fm.teal.alpha.actor.status&rkey=self` + ) + .then((res) => res.json()) + .then((data) => getRecordSchema(tealFmStatusSchema).safeParse(data).data) + .then((data) => + data + ? data.value.item.trackName + + ((data.value.item.artists.length > 0 + ? ` - ${data.value.item.artists[0].artistName}` + : "") + + (data.value.item.artists.length > 1 + ? `${data.value.item.artists.length - 1} more artist${data.value.item.artists.length > 1 ? "s" : ""}` + : "")) + : undefined + ); + + return ( + "- " + + [ + (await statusphere) + ? `${await displayName} (${await statusphere})` + : await displayName, + + `@${await handle}`, + `at://${did}`, + await nowPlaying, + ] + .filter((x) => x) + .join("\n ") + ); +} + +function landingPage(): Response { + // get upstream pds status + const status = fetch(`${PDS}/xrpc/_health`).then( + async (res) => `${res.status} ${res.statusText} ${await res.text()}` + ); + + const users = fetch(`${PDS}/xrpc/com.atproto.sync.listRepos`) + .then((res) => res.json()) + .then((data) => listReposSchema.safeParse(data).data) + .then((data) => + data ? data.repos.filter((x) => x.active).map((x) => x.did) : undefined + ) + .then(async (users) => + users + ? await Promise.all(users.map((did) => renderUser(did))).then((x) => + x.join("\n\n") + ) + : undefined + ); + + const body = new ReadableStream({ + async start(controller: ReadableStreamDefaultController) { + controller.enqueue(heading); + controller.enqueue(await status); + controller.enqueue("\n\n"); + controller.enqueue(await users); + controller.close(); + }, + }); + + // buffer the output just to be easier to handle + const stagger = new TransformStream({ + async transform( + chunk: string, + controller: TransformStreamDefaultController + ) { + for (const part of chunk) { + controller.enqueue(part); + await new Promise((res) => setTimeout(res, 10)); + } + }, + }); + + return new Response( + body.pipeThrough(stagger).pipeThrough(new TextEncoderStream()), + { + headers: { + "Content-Type": "text/text; charset=utf-8", + "Access-Control-Allow-Origin": "*", + Link: "; rel=stylesheet", + }, + } + ); +} + +Deno.serve( + { port: !Number.isNaN(PORT) && PORT <= 65535 && PORT > 0 ? PORT : 8000 }, + (req) => { + const path = new URL(req.url).pathname; + switch (path) { + case "/": + return landingPage(); + case "/styles.css": + return new Response(css, { + headers: { + "Content-Type": "text/css; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }, + }); + default: + return new Response(`404 Not Found: ${path} does not exist`, { + status: 400, + statusText: "Not Found", + }); + } + } +); diff --git a/landing/landing.ts b/landing/landing.ts deleted file mode 100644 index ec2f9c0..0000000 --- a/landing/landing.ts +++ /dev/null @@ -1,127 +0,0 @@ -import css from "./css.css" with { type: "text" }; -import heading from "./heading.txt" with { type: "text" }; - -Deno.serve({ port: 8000 }, (req) => { - switch (new URL(req.url).pathname) { - case "/css": - return new Response(css, { - headers: { - "Content-Type": "text/css; charset=utf-8", - "Access-Control-Allow-Origin": "*", - }, - }); - case "/": { - const body = new ReadableStream({ - async start(controller) { - // get upstream pds status - const status = fetch("http://pi:8000/xrpc/_health").then( - async (res) => - `${res.status} ${res.statusText}: ${await res.text()}` - ); - - // get list of users; - // get from pi since it's more reliable than external - // not awaited so it runs in background while streaming - const users = fetch("http://pi:8000/xrpc/com.atproto.sync.listRepos") - // type cast because no point validating for smthn like this - // real type has more info; not needed here - .then((res) => res.json() as Promise<{ repos: { did: string }[] }>) - .then((res) => - // get display name, handle, and did for each user - res.repos.map((repo) => ({ - display: fetch( - `http://pi:8000/xrpc/com.atproto.repo.getRecord?repo=${repo.did}&collection=app.bsky.actor.profile&rkey=self` - ) - .then((res) => res.json()) - .then((profile) => profile?.value?.displayName ?? repo.did), - // dont validate handles because I'm Lazy + trust myself - handle: fetch( - repo.did.startsWith("did:plc") - ? "https://plc.directory/" + repo.did - : `https://${repo.did.replace("did:web:", "")}/.well-known/did.json` - ) - .then((res) => res.json()) - .then((doc) => doc.alsoKnownAs[0].replace("at://", "")), - did: repo.did, - statusphere: fetch( - `http://pi:8000/xrpc/com.atproto.repo.listRecords?repo=${repo.did}&collection=xyz.statusphere.status` - ) - .then( - (res) => - res.json() as Promise<{ - records: { - cid: string; - value: { - status: string; - createdAt: string; - }; - }[]; - }> - ) - .then(({ records }) => - records.map((x) => ({ - cid: x.cid, - value: { - ...x.value, - createdAt: new Date(x.value.createdAt), - }, - })) - ) - .then((x) => - x - .sort( - (a, b) => - (b as unknown as number) - (a as unknown as number) - ) - .at(0) - ) - .then((x) => x?.value?.status), - })) - ) - .then(async (users) => - ( - await Promise.all( - users.map( - async (x) => - ` -- ${await x.display} - ${await x.handle} ${(await x.statusphere) ? `(${await x.statusphere})` : ""} - ${x.did}` - ) - ) - ).join("\n") - ); - - for (const char of heading) { - await new Promise((res) => setTimeout(res, 10)); - controller.enqueue(char); - } - - for (const char of "\n/xrpc/_health/ " + (await status) + "\n") { - await new Promise((res) => setTimeout(res, 10)); - controller.enqueue(char); - } - - for (const char of "\nAccounts:" + (await users)) { - await new Promise((res) => setTimeout(res, 10)); - controller.enqueue(char); - } - - controller.close(); - }, - }); - - return new Response(body.pipeThrough(new TextEncoderStream()), { - headers: { - "Content-Type": "text/text; charset=utf-8", - "Access-Control-Allow-Origin": "*", - Link: "; rel=stylesheet", - }, - }); - } - } - - return new Response("404", { - status: 404, - }); -}); diff --git a/landing/schemas.ts b/landing/schemas.ts new file mode 100644 index 0000000..a7158dd --- /dev/null +++ b/landing/schemas.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +export const listReposSchema = z.object({ + repos: z.array(z.object({ did: z.string(), active: z.boolean() })), +}); + +export const didDocSchema = z.object({ + alsoKnownAs: z.array(z.string()), +}); + +export const profileSelfSchema = z.object({ + $type: z.literal("app.bsky.actor.profile"), + displayName: z.string().optional(), +}); + +export const statusphereSchema = z.object({ + $type: z.literal("xyz.statusphere.status"), + status: z.string(), +}); + +export const tealFmStatusSchema = z.object({ + time: z.string(), + item: z.object({ + trackName: z.string(), + artists: z.array( + z.object({ + artistName: z.string(), + }) + ), + }), +}); + +export function getRecordSchema(record: T) { + return z.object({ + value: record, + }); +} + +export function listRecordsSchema(record: T) { + return z.object({ + records: z.array( + z.object({ + uri: z.string(), + cid: z.string(), + value: record, + }) + ), + }); +} diff --git a/landing/styles.css b/landing/styles.css new file mode 100644 index 0000000..b732f56 --- /dev/null +++ b/landing/styles.css @@ -0,0 +1,30 @@ +html { + background: repeating-linear-gradient(#fff1, #0000 5px, #fff1 10px), black; + min-height: 100%; + font-family: + "Monaspace neon var", + -moz-fixed, + monospace; + font-weight: bold; +} + +html, +body { + margin: 0; +} + +pre { + /* override some resource://content-accessible/plaintext.css styles in ff */ + white-space: pre !important; + width: max-content; + padding: 1em; + margin-block: 0; + padding-block-start: 0; + text-shadow: 0 0 5px lime; + color: lime; + + &::selection { + color: black; + background-color: lime; + } +} -- 2.51.2