An HTML-only Bluesky frontend

add more functionality to actor pages including follows and following

+259 -119
+1 -1
deno.json
··· 1 1 { 2 2 "tasks": { 3 - "dev": "deno run -A --watch main.ts" 3 + "dev": "deno run -A --watch main.tsx" 4 4 }, 5 5 "compilerOptions": { 6 6 "jsx": "react-jsx",
-60
main.ts
··· 1 - import { renderToStringAsync } from "npm:preact-render-to-string"; 2 - import { AtpAgent } from "npm:@atproto/api"; 3 - 4 - // page imports 5 - import { Actor } from "./pages/mod.ts"; 6 - 7 - export const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 8 - 9 - Deno.serve({ 10 - port: 8080, 11 - }, async (req) => { 12 - const url = new URL(req.url); 13 - const path = url.pathname; 14 - 15 - // handle trailing slashes 16 - if (path !== "/" && url.pathname.match(/\/+$/)) { 17 - return Response.redirect( 18 - `${url.protocol}${url.host}${url.pathname.replace(/\/+$/, "")}`, 19 - ); 20 - } 21 - 22 - // paths 23 - if (path === "/") { 24 - const htmlsky = await agent.api.app.bsky.actor.getProfile({ 25 - actor: "htmlsky.app", 26 - }).then((res) => res.data); 27 - 28 - return new Response( 29 - await renderToStringAsync(await Actor(htmlsky)), 30 - headers, 31 - ); 32 - } 33 - 34 - const profilePattern = new URLPattern({ pathname: "/profile/:actor" }); 35 - if (profilePattern.test(url)) { 36 - const actorName = profilePattern.exec(url)?.pathname.groups.actor; 37 - 38 - try { 39 - const actor = await agent.api.app.bsky.actor.getProfile({ 40 - actor: actorName!, 41 - }).then((res) => res.data); 42 - 43 - return new Response( 44 - await renderToStringAsync(await Actor(actor)), 45 - headers, 46 - ); 47 - } catch (e) { 48 - // TODO: add error page 49 - return new Response(e.message, headers); 50 - } 51 - } 52 - 53 - return Response.redirect(`${url.protocol}${url.host}`, 303); 54 - }); 55 - 56 - const headers: ResponseInit = { 57 - "headers": { 58 - "Content-Type": "text/html;charset=utf-8", 59 - }, 60 - };
+76
main.tsx
··· 1 + import { renderToStringAsync } from "npm:preact-render-to-string"; 2 + import { AtpAgent } from "npm:@atproto/api"; 3 + 4 + // page imports 5 + import * as pages from "./pages/mod.ts"; 6 + 7 + export const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 8 + 9 + Deno.serve({ 10 + port: 8080, 11 + }, async (req) => { 12 + const url = new URL(req.url); 13 + const path = url.pathname; 14 + 15 + // handle trailing slashes 16 + if (path.at(-1) !== "/") { 17 + return Response.redirect( 18 + `${url.protocol}${url.host}${url.pathname}/`, 19 + ); 20 + } 21 + 22 + const profilePattern = new URLPattern({ pathname: "/profile/:actor/" }); 23 + if (profilePattern.test(url)) { 24 + const actorName = profilePattern.exec(url)?.pathname.groups.actor; 25 + 26 + try { 27 + const actor = await agent.api.app.bsky.actor.getProfile({ 28 + actor: actorName!, 29 + }).then((res) => res.data); 30 + 31 + return new Response( 32 + await renderToStringAsync(await pages.Actor({ actor: actor })), 33 + headers, 34 + ); 35 + } catch (e) { 36 + // TODO: add error page 37 + return new Response(e.message, headers); 38 + } 39 + } 40 + const profilePagePattern = new URLPattern({ 41 + pathname: "/profile/:actor/:page/", 42 + }); 43 + if (profilePagePattern.test(url)) { 44 + const actorName = profilePagePattern.exec(url)?.pathname.groups.actor; 45 + const actor = await agent.api.app.bsky.actor.getProfile({ 46 + actor: actorName!, 47 + }).then((res) => res.data); 48 + const page = profilePagePattern.exec(url)?.pathname.groups.page; 49 + 50 + if (page === "followers") { 51 + // TODO: followers list 52 + return new Response( 53 + await renderToStringAsync(await pages.ActorFollowers({ actor: actor })), 54 + headers, 55 + ); 56 + } 57 + if (page === "follows") { 58 + // TODO: follows list 59 + return new Response( 60 + await renderToStringAsync(await pages.ActorFollows({ actor: actor })), 61 + headers, 62 + ); 63 + } 64 + } 65 + 66 + return Response.redirect( 67 + `${url.protocol}${url.host}/profile/htmlsky.app/`, 68 + 303, 69 + ); 70 + }); 71 + 72 + const headers: ResponseInit = { 73 + "headers": { 74 + "Content-Type": "text/html;charset=utf-8", 75 + }, 76 + };
-55
pages/actor.tsx
··· 1 - import { AppBskyActorDefs, AppBskyFeedDefs } from "npm:@atproto/api"; 2 - import sanitizeHtml from "npm:sanitize-html"; 3 - 4 - import { GetDescriptionFacets } from "../facets.ts"; 5 - 6 - // Components 7 - import { ActorHeader } from "./header.tsx"; 8 - 9 - export async function Actor( 10 - actor: AppBskyActorDefs.ProfileViewDetailed, 11 - // TODO: add posts from feed 12 - _feed: AppBskyFeedDefs.FeedViewPost, 13 - ) { 14 - return ( 15 - <> 16 - <ActorHeader {...actor} /> 17 - <table> 18 - <tr> 19 - <td> 20 - <img 21 - src={actor.avatar} 22 - alt={`${sanitizeHtml(actor.displayName)}'s avatar`} 23 - width="65" 24 - height="65" 25 - /> 26 - </td> 27 - <td> 28 - <h1 style="margin:0;">{sanitizeHtml(actor.displayName)}</h1> 29 - <span>@{actor.handle}</span> 30 - </td> 31 - </tr> 32 - </table> 33 - 34 - <p> 35 - <b>{actor.followersCount}</b> followers&nbsp; 36 - <b>{actor.followsCount}</b> following&nbsp; 37 - <b>{actor.postsCount}</b> posts&nbsp; 38 - </p> 39 - 40 - <p 41 - style="white-space:pre-line;" 42 - dangerouslySetInnerHTML={{ 43 - __html: await GetDescriptionFacets( 44 - sanitizeHtml(actor.description), 45 - ), 46 - }} 47 - > 48 - </p> 49 - 50 - <hr /> 51 - 52 - <p>A list of posts will be here eventually.</p> 53 - </> 54 - ); 55 - }
+39
pages/actor/followers.tsx
··· 1 + import { AppBskyActorDefs /*AppBskyFeedDefs*/ } from "npm:@atproto/api"; 2 + // import sanitizeHtml from "npm:sanitize-html"; 3 + 4 + // import { GetDescriptionFacets } from "../../facets.ts"; 5 + import { agent } from "../../main.tsx"; 6 + 7 + export async function ActorFollowers( 8 + { actor }: { actor: AppBskyActorDefs.ProfileViewDetailed }, 9 + ) { 10 + // TODO: implement cursor 11 + 12 + const followers: AppBskyActorDefs.ProfileViewDetailed[] = await agent.api.app 13 + .bsky.graph.getFollowers({ actor: actor.did }).then((res) => res.data).then( 14 + (res) => res.followers, 15 + ); 16 + 17 + const followersList: preact.VNode[] = []; 18 + 19 + followers.forEach((follower) => { 20 + followersList.push( 21 + <li> 22 + <a href={`/profile/${follower.did}`}>{follower.handle}</a> 23 + </li>, 24 + ); 25 + }); 26 + return ( 27 + <> 28 + <header> 29 + <a href="..">Back</a>&nbsp; 30 + <span> 31 + {actor.displayName ? actor.displayName : actor.handle}'s Followers 32 + </span> 33 + 34 + <hr /> 35 + </header> 36 + <ul>{followersList}</ul> 37 + </> 38 + ); 39 + }
+37
pages/actor/follows.tsx
··· 1 + import { AppBskyActorDefs /*AppBskyFeedDefs*/ } from "npm:@atproto/api"; 2 + // import sanitizeHtml from "npm:sanitize-html"; 3 + 4 + // import { GetDescriptionFacets } from "../../facets.ts"; 5 + import { agent } from "../../main.tsx"; 6 + import { ActorHeader } from "../header.tsx"; 7 + 8 + export async function ActorFollows( 9 + { actor }: { actor: AppBskyActorDefs.ProfileViewDetailed }, 10 + ) { 11 + // TODO: implement cursor 12 + 13 + const follows: AppBskyActorDefs.ProfileViewDetailed[] = await agent.api.app 14 + .bsky.graph.getFollows({ actor: actor.did }).then((res) => res.data).then( 15 + (res) => res.follows, 16 + ); 17 + 18 + const followList: preact.VNode[] = []; 19 + 20 + follows.forEach((follow) => { 21 + followList.push( 22 + <li> 23 + <a href={`/profile/${follow.did}`}>{follow.handle}</a> 24 + </li>, 25 + ); 26 + }); 27 + return ( 28 + <> 29 + <a href="..">Back</a>&nbsp; 30 + <span> 31 + {actor.displayName ? actor.displayName : actor.handle}'s Follows 32 + </span> 33 + <hr /> 34 + <ul>{followList}</ul> 35 + </> 36 + ); 37 + }
+80
pages/actor/index.tsx
··· 1 + import { AppBskyActorDefs /*AppBskyFeedDefs*/ } from "npm:@atproto/api"; 2 + import sanitizeHtml from "npm:sanitize-html"; 3 + 4 + import { GetDescriptionFacets } from "../../facets.ts"; 5 + 6 + // Components 7 + import { ActorHeader } from "../header.tsx"; 8 + 9 + export async function Actor( 10 + { actor }: { actor: AppBskyActorDefs.ProfileViewDetailed }, 11 + ) { 12 + return ( 13 + <> 14 + <ActorHeader {...actor} /> 15 + <table draggable={false}> 16 + <tr> 17 + <td> 18 + <img 19 + src={actor.avatar 20 + ? actor.avatar 21 + : "https://htmlsky.app/avatar.jpeg"} 22 + alt={`${sanitizeHtml(actor.displayName)}'s avatar`} 23 + width="90" 24 + /> 25 + </td> 26 + <td>&nbsp;</td> 27 + <td> 28 + <small> 29 + <small> 30 + <small> 31 + <span>&nbsp;</span> 32 + </small> 33 + </small> 34 + </small> 35 + <h1> 36 + {actor.displayName ? actor.displayName : actor.handle} 37 + <br /> 38 + <small> 39 + <small> 40 + <small> 41 + <i>@{actor.handle}</i> 42 + </small> 43 + </small> 44 + </small> 45 + </h1> 46 + </td> 47 + </tr> 48 + </table> 49 + 50 + <p> 51 + <span> 52 + <a href="./followers"> 53 + <b>{actor.followersCount}</b> followers 54 + </a> 55 + </span>&nbsp; 56 + <span> 57 + <a href="./follows"> 58 + <b>{actor.followsCount}</b> following 59 + </a> 60 + </span>&nbsp; 61 + <span> 62 + <b>{actor.postsCount}</b> posts 63 + </span>&nbsp; 64 + </p> 65 + 66 + <p 67 + dangerouslySetInnerHTML={{ 68 + __html: (await GetDescriptionFacets( 69 + sanitizeHtml(actor.description), 70 + )).replaceAll("\n", "<br>"), 71 + }} 72 + > 73 + </p> 74 + 75 + <hr /> 76 + 77 + <p>A list of posts will be here eventually.</p> 78 + </> 79 + ); 80 + }
+3
pages/actor/mod.ts
··· 1 + export * from "./index.tsx"; 2 + export * from "./followers.tsx"; 3 + export * from "./follows.tsx";
+18
pages/head.tsx
··· 1 + const Head = () => { 2 + return ( 3 + <header> 4 + <nav> 5 + <span> 6 + <b> 7 + <i>HTMLsky</i> 8 + </b>&nbsp; 9 + </span> 10 + [ <a href="/">Home</a> ] [{" "} 11 + <a href="https://sr.ht/~jordanreger/htmlsky">Source</a> ] [{" "} 12 + <a href={`https://bsky.app/profile/${actor.did}`}>View on Bluesky</a> ] 13 + </nav> 14 + 15 + <hr /> 16 + </header> 17 + ); 18 + };
+4 -2
pages/header.tsx
··· 9 9 <i>HTMLsky</i> 10 10 </b>&nbsp; 11 11 </span> 12 - 13 12 [ <a href="/">Home</a> ] [{" "} 14 13 <a href="https://sr.ht/~jordanreger/htmlsky">Source</a> ] [{" "} 15 - <a href={`https://bsky.app/profile/${actor.did}`}>View on Bluesky</a> ] 14 + <a href={`https://bsky.app/profile/${actor.did}`}> 15 + View on Bluesky 16 + </a>{" "} 17 + ] 16 18 </nav> 17 19 18 20 <hr />
+1 -1
pages/mod.ts
··· 1 - export * from "./actor.tsx"; 1 + export * from "./actor/mod.ts"; 2 2 export * from "./head.tsx"; 3 3 export * from "./header.tsx";