an attempt to make a lightweight, easily self-hostable, scoped bluesky appview

more frontend stuff

rimar1337 c97cf44f e064ac2d

docs/assets/indexapistatus.png

This is a binary file and will not be displayed.

docs/assets/viewapistatus.png

This is a binary file and will not be displayed.

+33
indexserver.ts
··· 3 3 import { assertRecord, validateRecord } from "./utils/records.ts"; 4 4 import { 5 5 buildBlobUrl, 6 + getSlingshotRecord, 6 7 resolveIdentity, 7 8 searchParamsToJson, 8 9 withCors, ··· 87 88 88 89 public handlesDid(did: string): boolean { 89 90 return this.userManager.handlesDid(did); 91 + } 92 + async unspeccedGetRegisteredUsers(): Promise<{ 93 + did: string; 94 + role: string; 95 + registrationdate: string; 96 + onboardingstatus: string; 97 + pfp?: string; 98 + displayname: string; 99 + handle: string; 100 + }[]|undefined> { 101 + const stmt = this.systemDB.prepare(` 102 + SELECT * 103 + FROM users; 104 + `); 105 + const result = stmt.all() as 106 + { 107 + did: string; 108 + role: string; 109 + registrationdate: string; 110 + onboardingstatus: string; 111 + }[]; 112 + const hydrated = await Promise.all( result.map(async (user)=>{ 113 + const identity = await resolveIdentity(user.did); 114 + const profile = (await getSlingshotRecord(identity.did,"app.bsky.actor.profile","self")).value as ATPAPI.AppBskyActorProfile.Record; 115 + const avatarcid = uncid(profile.avatar?.ref); 116 + const avatar = avatarcid 117 + ? buildBlobUrl(identity.pds, identity.did, avatarcid) 118 + : undefined; 119 + return {...user,handle: identity.handle,pfp: avatar, displayname:profile.displayName ?? identity.handle } 120 + })) 121 + //const exists = result !== undefined; 122 + return hydrated; 90 123 } 91 124 92 125 // We will move all the global functions into this class as methods...
+64
main-index.ts
··· 67 67 // */ 68 68 // "/xrpc/party.whey.app.bsky.feed.getListFeedPartial", 69 69 // ]); 70 + const placeholderselfcheckstatus = { 71 + "#skylite_index:/xrpc/app.bsky.actor.getProfile": "green", 72 + "#skylite_index:/xrpc/app.bsky.actor.getProfiles": "green", 73 + "#skylite_index:/xrpc/app.bsky.feed.getActorFeeds": "green", 74 + "#skylite_index:/xrpc/app.bsky.feed.getFeedGenerator": "green", 75 + "#skylite_index:/xrpc/app.bsky.feed.getFeedGenerators": "green", 76 + "#skylite_index:/xrpc/app.bsky.feed.getPosts": "green", 77 + "#skylite_index:/xrpc/app.bsky.graph.getLists": "black", 78 + "#skylite_index:/xrpc/app.bsky.graph.getList": "black", 79 + "#skylite_index:/xrpc/app.bsky.graph.getActorStarterPacks": "black", 80 + "#skylite_index:/xrpc/party.whey.app.bsky.feed.getActorLikesPartial": "green", 81 + "#skylite_index:/xrpc/party.whey.app.bsky.feed.getAuthorFeedPartial": "green", 82 + "#skylite_index:/xrpc/party.whey.app.bsky.feed.getLikesPartial": "orange", 83 + "#skylite_index:/xrpc/party.whey.app.bsky.feed.getPostThreadPartial": "green", 84 + "#skylite_index:/xrpc/party.whey.app.bsky.feed.getQuotesPartial": "orange", 85 + "#skylite_index:/xrpc/party.whey.app.bsky.feed.getRepostedByPartial": "orange", 86 + "#skylite_index:/xrpc/party.whey.app.bsky.feed.getListFeedPartial": "black", 87 + 70 88 89 + 90 + 91 + "constellation:/links": "green", 92 + "constellation:/links/distinct-dids": "green", 93 + "constellation:/links/count": "green", 94 + "constellation:/links/count/distinct-dids": "green", 95 + "constellation:/links/all": "green", 96 + }; 71 97 72 98 73 99 //console.log("ready to serve"); ··· 77 103 const url = new URL(req.url); 78 104 const pathname = url.pathname; 79 105 const searchParams = searchParamsToJson(url.searchParams); 106 + 107 + const publicdir = "/public" 108 + if (pathname.startsWith(publicdir)) { 109 + const filepath = decodeURIComponent(pathname.slice(publicdir.length)); 110 + try { 111 + const file = await Deno.open("./public" + filepath, { read: true }); 112 + return new Response(file.readable); 113 + } catch { 114 + return new Response("404 Not Found", { status: 404 }); 115 + } 116 + } 117 + 118 + const todopleasespecthis = "/_unspecced" 119 + if (pathname.startsWith(todopleasespecthis)) { 120 + const unspeccedroute = decodeURIComponent(pathname.slice(todopleasespecthis.length)); 121 + if (unspeccedroute === "/config") { 122 + const safeconfig = { 123 + inviteOnly: config.indexServer.inviteOnly, 124 + //port: number, 125 + did: config.indexServer.did, 126 + host: config.indexServer.host, 127 + } 128 + return new Response(JSON.stringify(safeconfig), { 129 + headers: withCors({ "content-type": "application/json; charset=utf-8" }), 130 + }); 131 + } 132 + if (unspeccedroute === "/users") { 133 + const res = await genericIndexServer.unspeccedGetRegisteredUsers() 134 + return new Response(JSON.stringify(res), { 135 + headers: withCors({ "content-type": "application/json; charset=utf-8" }), 136 + }); 137 + } 138 + if (unspeccedroute === "/apitest") { 139 + return new Response(JSON.stringify(placeholderselfcheckstatus), { 140 + headers: withCors({ "content-type": "application/json; charset=utf-8" }), 141 + }); 142 + } 143 + } 80 144 81 145 if (html && js) { 82 146 if (pathname === "/" || pathname === "") {
+100
main-view.ts
··· 50 50 // XRPC Method Implementations 51 51 // ------------------------------------------ 52 52 53 + const placeholderselfcheckstatus = { 54 + "#bsky_appview:/xrpc/app.bsky.actor.getPreferences": "black", 55 + "#bsky_appview:/xrpc/app.bsky.actor.getProfile": "green", 56 + "#bsky_appview:/xrpc/app.bsky.actor.getProfiles": "green", 57 + "#bsky_appview:/xrpc/app.bsky.actor.getSuggestions": "black", 58 + "#bsky_appview:/xrpc/app.bsky.actor.putPreferences": "black", 59 + "#bsky_appview:/xrpc/app.bsky.actor.searchActorsTypeahead": "black", 60 + "#bsky_appview:/xrpc/app.bsky.actor.searchActors": "black", 61 + "#bsky_appview:/xrpc/app.bsky.feed.describeFeedGenerator": "black", 62 + "#bsky_appview:/xrpc/app.bsky.feed.getActorFeeds": "black", 63 + "#bsky_appview:/xrpc/app.bsky.feed.getActorLikes": "black", 64 + "#bsky_appview:/xrpc/app.bsky.feed.getAuthorFeed": "red", 65 + "#bsky_appview:/xrpc/app.bsky.feed.getFeedGenerator": "black", 66 + "#bsky_appview:/xrpc/app.bsky.feed.getFeedGenerators": "green", // arguably 67 + "#bsky_appview:/xrpc/app.bsky.feed.getFeedSkeleton": "black", 68 + "#bsky_appview:/xrpc/app.bsky.feed.getFeed": "red", 69 + "#bsky_appview:/xrpc/app.bsky.feed.getLikes": "black", 70 + "#bsky_appview:/xrpc/app.bsky.feed.getListFeed": "black", 71 + "#bsky_appview:/xrpc/app.bsky.feed.getPostThread": "red", 72 + "#bsky_appview:/xrpc/app.bsky.unspecced.getPostThreadV2": "red", 73 + "#bsky_appview:/xrpc/app.bsky.feed.getPosts": "green", 74 + "#bsky_appview:/xrpc/app.bsky.feed.getQuotes": "black", 75 + "#bsky_appview:/xrpc/app.bsky.feed.getRepostedBy": "black", 76 + "#bsky_appview:/xrpc/app.bsky.feed.getSuggestedFeeds": "black", 77 + "#bsky_appview:/xrpc/app.bsky.feed.getTimeline": "black", 78 + "#bsky_appview:/xrpc/app.bsky.feed.searchPosts": "black", 79 + "#bsky_appview:/xrpc/app.bsky.feed.sendInteractions": "black", 80 + "#bsky_appview:/xrpc/app.bsky.graph.getActorStarterPacks": "black", 81 + "#bsky_appview:/xrpc/app.bsky.graph.getBlocks": "black", 82 + "#bsky_appview:/xrpc/app.bsky.graph.getFollowers": "black", 83 + "#bsky_appview:/xrpc/app.bsky.graph.getFollows": "black", 84 + "#bsky_appview:/xrpc/app.bsky.graph.getKnownFollowers": "black", 85 + "#bsky_appview:/xrpc/app.bsky.graph.getListBlocks": "black", 86 + "#bsky_appview:/xrpc/app.bsky.graph.getListMutes": "black", 87 + "#bsky_appview:/xrpc/app.bsky.graph.getList": "black", 88 + "#bsky_appview:/xrpc/app.bsky.graph.getLists": "black", 89 + "#bsky_appview:/xrpc/app.bsky.graph.getMutes": "black", 90 + "#bsky_appview:/xrpc/app.bsky.graph.getRelationships": "black", 91 + "#bsky_appview:/xrpc/app.bsky.graph.getStarterPack": "black", 92 + "#bsky_appview:/xrpc/app.bsky.graph.getStarterPacks": "black", 93 + "#bsky_appview:/xrpc/app.bsky.graph.getSuggestedFollowsByActor": "black", 94 + "#bsky_appview:/xrpc/app.bsky.graph.muteActorList": "black", 95 + "#bsky_appview:/xrpc/app.bsky.graph.muteActor": "black", 96 + "#bsky_appview:/xrpc/app.bsky.graph.muteThread": "black", 97 + "#bsky_appview:/xrpc/app.bsky.graph.searchStarterPacks": "black", 98 + "#bsky_appview:/xrpc/app.bsky.graph.unmuteActorList": "black", 99 + "#bsky_appview:/xrpc/app.bsky.graph.unmuteActor": "black", 100 + "#bsky_appview:/xrpc/app.bsky.graph.unmuteThread": "black", 101 + "#bsky_appview:/xrpc/app.bsky.labeler.getServices": "black", 102 + "#bsky_appview:/xrpc/app.bsky.notification.getUnreadCount": "black", 103 + "#bsky_appview:/xrpc/app.bsky.notification.listNotifications": "green", 104 + "#bsky_appview:/xrpc/app.bsky.notification.putPreferences": "black", 105 + "#bsky_appview:/xrpc/app.bsky.notification.registerPush": "black", 106 + "#bsky_appview:/xrpc/app.bsky.notification.updateSeen": "black", 107 + "#bsky_appview:/xrpc/app.bsky.video.getJobStatus": "black", 108 + "#bsky_appview:/xrpc/app.bsky.video.getUploadLimits": "black", 109 + "#bsky_appview:/xrpc/app.bsky.video.uploadVideo": "black", 110 + "#bsky_appview:/xrpc/app.bsky.unspecced.getTrendingTopics":"red", 111 + "#bsky_appview:/xrpc/app.bsky.unspecced.getConfig":"red", 112 + } 113 + 53 114 Deno.serve( 54 115 { port: config.viewServer.port }, 55 116 async (req: Request): Promise<Response> => { 56 117 const url = new URL(req.url); 57 118 const pathname = url.pathname; 58 119 const searchParams = searchParamsToJson(url.searchParams); 120 + 121 + const publicdir = "/public" 122 + if (pathname.startsWith(publicdir)) { 123 + const filepath = decodeURIComponent(pathname.slice(publicdir.length)); 124 + try { 125 + const file = await Deno.open("." + filepath, { read: true }); 126 + return new Response(file.readable); 127 + } catch { 128 + return new Response("404 Not Found", { status: 404 }); 129 + } 130 + } 131 + 132 + const todopleasespecthis = "/_unspecced" 133 + if (pathname.startsWith(todopleasespecthis)) { 134 + const unspeccedroute = decodeURIComponent(pathname.slice(todopleasespecthis.length)); 135 + if (unspeccedroute === "/config") { 136 + const safeconfig = { 137 + inviteOnly: config.viewServer.inviteOnly, 138 + //port: number, 139 + did: config.viewServer.did, 140 + host: config.viewServer.host, 141 + indexPriority: config.viewServer.indexPriority, 142 + } 143 + return new Response(JSON.stringify(safeconfig), { 144 + headers: withCors({ "content-type": "application/json; charset=utf-8" }), 145 + }); 146 + } 147 + if (unspeccedroute === "/users") { 148 + const res = await genericViewServer.unspeccedGetRegisteredUsers() 149 + return new Response(JSON.stringify(res), { 150 + headers: withCors({ "content-type": "application/json; charset=utf-8" }), 151 + }); 152 + } 153 + if (unspeccedroute === "/apitest") { 154 + return new Response(JSON.stringify(placeholderselfcheckstatus), { 155 + headers: withCors({ "content-type": "application/json; charset=utf-8" }), 156 + }); 157 + } 158 + } 59 159 60 160 if (html && js) { 61 161 if (pathname === "/" || pathname === "") {
public/index.ico

This is a binary file and will not be displayed.

public/view.ico

This is a binary file and will not be displayed.

+7 -68
readme.md
··· 1 1 # skylite (pre alpha) 2 2 an attempt to make a lightweight, easily self-hostable, scoped Bluesky appview 3 3 4 + (as of 28 aug 2025) 5 + currently the state of the project is: 6 + ![screenshot of the index server](./docs/assets/indexapistatus.png) 7 + ![screenshot of the view server](./docs/assets/viewapistatus.png) 8 + 4 9 this project uses: 5 10 - live sync systems: [jetstream](https://github.com/bluesky-social/jetstream) and [spacedust](https://spacedust.microcosm.blue/) 6 11 - backfill: [listRecords](https://docs.bsky.app/docs/api/com-atproto-repo-list-records) and [constellation](https://constellation.microcosm.blue/) 7 12 - the backend server stuff: [sqlite](https://jsr.io/@db/sqlite) db, typescript with [codegen](https://www.npmjs.com/package/@atproto/lex-cli), and [deno](https://deno.com/) 8 13 - frontend: still deno and esbuild and tailwind and react and jsx and typescript (was fun getting these to run on deno) 9 14 10 - ## Status 11 - (as of 27 aug 2025) 12 - currently the state of the project is: 13 - ### Index Server 14 - - Database: 15 - - Works, though it still needs some more tuning and iteration 16 - - Registration: 17 - - not there yet. currently manually adding users 18 - - onboarding backfill is probably next maybe 19 - - got a fancy new ui (not finished) 20 - - Indexing: 21 - - Jetstream: 22 - - its there, i just need to actually handle each and every record type and insert it to the db (like currently 2 out of 12 or so record collections are being inserted into the db) 23 - - Spacedust: 24 - - its a backlink index so i only needed one table, and so it is complete 25 - - Server: 26 - - Initial implementation is done 27 - - uses per-user instantiation thing so it can add or remove users as needed 28 - - pagination is not a thing yet \:\( 29 - - does not implement the Ref / Partial routes yet (currently strips undefineds) (fixing this soon) 30 - - also implements the entirety of the Constellation API routes as a bonus (under `/links/`) 31 - - Lexicon: 32 - - unsure about PostViewRef's optional fields 33 - - theres 3 remaining profile-related routes thats still not defined yet 34 - - some routes need more tweaks 35 - - considering adding optional query params to request either skeleton only, partials, or full hydrated (might not be respected by the server though lol) 36 - - considering making all of the api routes custom instead of the current situation of having some of the Index server routes be the original unmodified bsky.app routes 37 - 38 - ### View Server 39 - - Registration: 40 - - got a fancy new ui (not finished) 41 - - no backfill yet 42 - - Bsky API Routes: 43 - - currently mostly just proxies api.bsky.app 44 - - Notifications works ! thanks to spacedust 45 - - Following feed is probably next 46 - - Database: 47 - - i havent split the DB between the Index server and View server yet 48 - - Hydration (resolving `Ref`s, handling partials): 49 - - It works! not implemented for all routes yet but it can find a route i think maybe idk 50 - - it does now have a ranking system to decide which index server to be prioritized if multiple index servers indexes the same user account. (and also supports both api sets (Bluesky AppView API (legacy/fallback) and Bluesky Index Server API )) 51 - 52 15 ## Running 53 16 this project is pre-alpha and not intended for general use yet. you are welcome to experiment if you dont mind errors or breaking changes. 54 17 ··· 63 26 deno task index 64 27 ``` 65 28 it should just work actually 66 - implemented xrpc routes for `#skylite_index` are: 67 - ```ts 68 - const indexServerRoutes = new Set([ 69 - "/xrpc/app.bsky.actor.getProfile", 70 - "/xrpc/app.bsky.actor.getProfiles", 71 - "/xrpc/app.bsky.feed.getActorFeeds", 72 - "/xrpc/app.bsky.feed.getFeedGenerator", 73 - "/xrpc/app.bsky.feed.getFeedGenerators", 74 - "/xrpc/app.bsky.feed.getPosts", 75 - "/xrpc/party.whey.app.bsky.feed.getActorLikesPartial", 76 - "/xrpc/party.whey.app.bsky.feed.getAuthorFeedPartial", 77 - "/xrpc/party.whey.app.bsky.feed.getLikesPartial", 78 - "/xrpc/party.whey.app.bsky.feed.getPostThreadPartial", 79 - "/xrpc/party.whey.app.bsky.feed.getQuotesPartial", 80 - "/xrpc/party.whey.app.bsky.feed.getRepostedByPartial", 81 - // i havent implemented these three yet 82 - /* 83 - app.bsky.graph.getLists // doesnt need to because theres no items[], and its self ProfileViewBasic 84 - app.bsky.graph.getList // needs to be Partial-ed (items[] union with ProfileViewRef) 85 - app.bsky.graph.getActorStarterPacks // maybe doesnt need to be Partial-ed because its self ProfileViewBasic 86 - */ 87 - 88 - // and the last one is a stub because its pretty hard to do 89 - "/xrpc/party.whey.app.bsky.feed.getListFeedPartial", 90 - ]); 91 - ``` 92 29 93 30 there is no way to register users to be indexed by the server yet (either Index nor View servers) so you can just manually add your account to the `system.db` file for now 94 31 ··· 100 37 expose your localhost to the web using a tunnel or something and use that url as the custom appview url 101 38 102 39 this should work on any bluesky client that supports changing the appview URL (im using an unreleased custom fork for development) as the view server implements the `#bsky_appview` routes for compatibility with existing clients 40 + 41 + ive got a custom `social-app` fork here [https://github.com/rimar1337/social-app/tree/publicappview-colorable](https://github.com/rimar1337/social-app/tree/publicappview-colorable) 103 42 104 43 the view server has extra configurations that you need to understand. 105 44 the view server hydrates content by calling other servers (either an `#skylite_index` or `#bsky_appview`) and so you need to write the order of which servers are prioritized first for resolving the hydration endpoints
+10
shared-landing/browser/icons.tsx
··· 1 + import React from 'https://esm.sh/react@19.1.1'; 2 + import type { SVGProps } from 'https://esm.sh/react@19.1.1'; 3 + // Font Awesome Regularby Dave Gandy 4 + // CC BY 4.0 Attribution Required 5 + export function Fa7RegularMap(props: SVGProps<SVGSVGElement>) { 6 + return (<svg xmlns="http://www.w3.org/2000/svg" width={360} height={360} viewBox="0 0 512 512" {...props}><path fill="currentColor" d="M512 48c0-8.3-4.3-16-11.3-20.4s-15.9-4.8-23.3-1.1L352.5 88.1L180 29.4c-13.7-4.7-28.7-3.8-41.9 2.3L13.8 90.3C5.4 94.2 0 102.7 0 112v352c0 8.2 4.2 15.9 11.1 20.3s15.6 4.9 23.1 1.4l127.3-59.9l170.7 56.9c13.7 4.6 28.5 3.7 41.6-2.5l124.4-58.5c8.4-4 13.8-12.4 13.8-21.7zM144 82.1v299l-96 45.2v-299zm48 303.3V84.3l128 43.5v300.3zM368 134l96-47.4v298.2L368 430z"></path></svg>); 7 + } 8 + export function Fa7RegularContactBook(props: SVGProps<SVGSVGElement>) { 9 + return (<svg xmlns="http://www.w3.org/2000/svg" width={360} height={360} viewBox="0 0 512 512" {...props}><path fill="currentColor" d="M384 48c8.8 0 16 7.2 16 16v384c0 8.8-7.2 16-16 16H96c-8.8 0-16-7.2-16-16V64c0-8.8 7.2-16 16-16zM96 0C60.7 0 32 28.7 32 64v384c0 35.3 28.7 64 64 64h288c35.3 0 64-28.7 64-64V64c0-35.3-28.7-64-64-64zm144 248a56 56 0 1 0 0-112a56 56 0 1 0 0 112m-32 40c-44.2 0-80 35.8-80 80c0 8.8 7.2 16 16 16h192c8.8 0 16-7.2 16-16c0-44.2-35.8-80-80-80zM512 80c0-8.8-7.2-16-16-16s-16 7.2-16 16v64c0 8.8 7.2 16 16 16s16-7.2 16-16zm-16 112c-8.8 0-16 7.2-16 16v64c0 8.8 7.2 16 16 16s16-7.2 16-16v-64c0-8.8-7.2-16-16-16m16 144c0-8.8-7.2-16-16-16s-16 7.2-16 16v64c0 8.8 7.2 16 16 16s16-7.2 16-16z"></path></svg>); 10 + }
+658 -176
shared-landing/browser/landing-shared.tsx
··· 1 - import React, { useState } from "https://esm.sh/react@19.1.1"; 2 - import { createRoot, hydrateRoot } from "https://esm.sh/react-dom@19.1.1/client"; 1 + import React, { 2 + useState, 3 + useEffect, 4 + PropsWithChildren, 5 + } from "https://esm.sh/react@19.1.1"; 6 + import { 7 + createRoot, 8 + hydrateRoot, 9 + } from "https://esm.sh/react-dom@19.1.1/client"; 3 10 import * as ATPAPI from "https://esm.sh/@atproto/api"; 4 - import { AuthProvider } from "./passauthprovider.tsx"; 11 + import { AuthProvider, useAuth } from "./passauthprovider.tsx"; 5 12 import Login from "./passlogin.tsx"; 6 - //import "./app.css"; 13 + import { Fa7RegularContactBook, Fa7RegularMap } from "./icons.tsx"; 14 + import { 15 + boolean, 16 + number, 17 + string, 18 + } from "https://esm.sh/zod@3.25.76/index.d.cts"; 19 + console.log("script loaded"); 7 20 8 - console.log("script loaded"); 21 + // TODO it should read from the config.jsonc instead 22 + const instanceConfig = { 23 + name: "demo instance", 24 + description: "test instance for demo-ing skylite", 25 + repoUrl: "https://tangled.sh/@whey.party/skylite", 26 + inviteRequired: true, 27 + socialAppUrl: "https://bsky.app", 28 + }; 9 29 10 - const exampleUri = new ATPAPI.AtUri("wow"); 11 - const dummyUsers = [ 12 - { 13 - pfp: "https://i.pravatar.cc/150?u=alice", 14 - displayName: `Alice${exampleUri}`, 15 - handle: "@alice.bsky.social", 16 - }, 17 - { 18 - pfp: "https://i.pravatar.cc/150?u=bob", 19 - displayName: "Bob The Builder", 20 - handle: "@bob.test", 21 - }, 22 - { 23 - pfp: "https://i.pravatar.cc/150?u=carol", 24 - displayName: "Carol", 25 - handle: "@carol.dev", 26 - }, 27 - { 28 - pfp: "https://i.pravatar.cc/150?u=dave", 29 - displayName: "Dave", 30 - handle: "@dave.codes", 31 - }, 32 - { 33 - pfp: "https://i.pravatar.cc/150?u=alice", 34 - displayName: "Alice", 35 - handle: "@alice.bsky.social", 36 - }, 37 - { 38 - pfp: "https://i.pravatar.cc/150?u=bob", 39 - displayName: "Bob The Builder", 40 - handle: "@bob.test", 41 - }, 42 - { 43 - pfp: "https://i.pravatar.cc/150?u=carol", 44 - displayName: "Carol", 45 - handle: "@carol.dev", 46 - }, 47 - { 48 - pfp: "https://i.pravatar.cc/150?u=dave", 49 - displayName: "Dave", 50 - handle: "@dave.codes", 51 - }, 52 - { 53 - pfp: "https://i.pravatar.cc/150?u=alice", 54 - displayName: "Alice", 55 - handle: "@alice.bsky.social", 56 - }, 57 - { 58 - pfp: "https://i.pravatar.cc/150?u=bob", 59 - displayName: "Bob The Builder", 60 - handle: "@bob.test", 61 - }, 62 - { 63 - pfp: "https://i.pravatar.cc/150?u=carol", 64 - displayName: "Carol", 65 - handle: "@carol.dev", 66 - }, 67 - { 68 - pfp: "https://i.pravatar.cc/150?u=dave", 69 - displayName: "Dave", 70 - handle: "@dave.codes", 71 - }, 72 - ]; 30 + const Card = ({ 31 + children, 32 + className = "", 33 + }: PropsWithChildren<{ className?: string }>) => ( 34 + <div className={`bg-white p-6 rounded-lg shadow-md ${className}`}> 35 + {children} 36 + </div> 37 + ); 73 38 74 - function UserList({ 75 - users, 39 + function Header({ 40 + isLoggedIn, 41 + agent, 42 + isIndex, 43 + capitaltitle, 44 + instancehost, 76 45 }: { 77 - users: { 78 - pfp: string; 79 - displayName: string; 80 - handle: string; 81 - }[]; 46 + isLoggedIn: boolean; 47 + agent?: ATPAPI.AtpAgent; 48 + isIndex: boolean; 49 + capitaltitle: string; 50 + instancehost?: string; 82 51 }) { 52 + const userHandle = agent?.session?.handle; 83 53 return ( 84 - <div className="w-full"> 85 - <h2 className="text-xl font-semibold text-gray-700 mb-4"> 86 - Registered Users ({users.length}) 87 - </h2> 88 - <div className="space-y-4 max-h-[300px] min-h-[300px] overflow-y-scroll"> 89 - {users.map((user) => ( 90 - <div key={user.handle} className="flex items-center space-x-4"> 91 - <img 92 - src={user.pfp} 93 - alt={user.displayName} 94 - className="w-12 h-12 rounded-full bg-gray-200" 95 - /> 96 - <div> 97 - <p className="font-bold text-gray-800">{user.displayName}</p> 98 - <p className="text-sm text-gray-500">{user.handle}</p> 54 + <header className="bg-white shadow-sm sticky top-0 z-10"> 55 + <nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> 56 + <div className="flex items-center justify-between h-16"> 57 + <div className="flex items-center"> 58 + <div className="flex-shrink-0 flex items-center gap-3 text-2xl font-bold text-blue-600"> 59 + {isIndex ? ( 60 + <Fa7RegularContactBook className="h-[1em] w-[1em]" /> 61 + ) : ( 62 + <Fa7RegularMap className="h-[1em] w-[1em]" /> 63 + )} 64 + <span> 65 + Skylite {capitaltitle} Server{" "} 66 + {instancehost && ( 67 + <span className="text-sm">({/*instancehost*/"alpha"})</span> 68 + )} 69 + </span> 99 70 </div> 100 71 </div> 101 - ))} 72 + <div className="flex items-center gap-4"> 73 + {isLoggedIn && userHandle && ( 74 + <span className="text-gray-700 hidden sm:block"> 75 + Welcome, <span className="font-semibold">@{userHandle}</span> 76 + </span> 77 + )} 78 + <Login compact /> 79 + </div> 80 + </div> 81 + </nav> 82 + </header> 83 + ); 84 + } 85 + 86 + function DataManager({ agent }: { agent?: ATPAPI.AtpAgent }) { 87 + const userDid = agent?.session?.did; 88 + return ( 89 + <Card> 90 + <h2 className="text-xl font-bold text-gray-800 mb-4">My Data Manager</h2> 91 + <p className="text-gray-600 mb-2"> 92 + Manage your data and account settings on this server. 93 + </p> 94 + <p className="text-sm text-gray-600 mb-4">Your DID:</p> 95 + <pre className="p-2 bg-gray-100 text-gray-800 rounded-md overflow-x-auto text-xs"> 96 + {userDid} 97 + </pre> 98 + <div className="mt-6 border-t pt-4"> 99 + <h3 className="text-lg font-semibold text-gray-700">Account Actions</h3> 100 + <p className="text-gray-500 text-sm mt-2"> 101 + (Feature in development) Actions like exporting or deleting your data 102 + will be available here. 103 + </p> 104 + <button 105 + disabled 106 + className="mt-4 px-4 py-2 rounded-md bg-red-600 text-white font-semibold shadow-sm disabled:bg-red-300 disabled:cursor-not-allowed" 107 + > 108 + Delete My Account from this Server 109 + </button> 102 110 </div> 103 - </div> 111 + </Card> 104 112 ); 105 113 } 106 114 107 - function RegistrationForm({ isLoggedIn }: { isLoggedIn: boolean }) { 115 + function ApiTester() { 116 + const [endpoint, setEndpoint] = useState("/xrpc/app.bsky.feed.getPosts"); 117 + const [method, setMethod] = useState("GET"); 118 + const [body, setBody] = useState(""); 119 + const [response, setResponse] = useState<string | null>(null); 120 + const [error, setError] = useState(""); 121 + const [isLoading, setIsLoading] = useState(false); 122 + 123 + const handleSubmit = async (e: any) => { 124 + e.preventDefault(); 125 + setIsLoading(true); 126 + setError(""); 127 + setResponse(null); 128 + try { 129 + const options: any = { 130 + method, 131 + headers: { "Content-Type": "application/json" }, 132 + }; 133 + if (method !== "GET" && method !== "HEAD" && body) { 134 + options.body = body; 135 + } 136 + const res = await fetch(endpoint, options); 137 + try { 138 + const data = await res.clone().json(); 139 + setResponse(JSON.stringify(data, null, 2)); 140 + } catch (_e) { 141 + setResponse(await res.text()); 142 + } 143 + if (!res.ok) { 144 + setError(`HTTP error! status: ${res.status}`); 145 + } 146 + } catch (err: any) { 147 + setError(err.message); 148 + setResponse(null); 149 + } finally { 150 + setIsLoading(false); 151 + } 152 + }; 153 + 108 154 return ( 109 - <div className="w-full"> 110 - <h2 className="text-xl font-semibold text-gray-700 mb-4"> 111 - Register a new account 112 - </h2> 113 - <p className="text-sm text-gray-500 mb-4"> 114 - You must be logged in with an invite code to register. 115 - </p> 116 - <fieldset 117 - disabled={!isLoggedIn} 118 - className="space-y-4 disabled:opacity-50 disabled:cursor-not-allowed" 119 - > 120 - <div> 121 - <label 122 - htmlFor="handle" 123 - className="block text-sm font-medium text-gray-600" 124 - > 125 - Handle 126 - </label> 127 - <input 128 - type="text" 129 - id="handle" 130 - placeholder="your-handle.bsky.social" 131 - className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" 132 - /> 133 - </div> 134 - <div> 135 - <label 136 - htmlFor="email" 137 - className="block text-sm font-medium text-gray-600" 138 - > 139 - Email 140 - </label> 141 - <input 142 - type="email" 143 - id="email" 144 - placeholder="you@example.com" 145 - className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" 146 - /> 147 - </div> 148 - <div> 149 - <label 150 - htmlFor="invite" 151 - className="block text-sm font-medium text-gray-600" 155 + <Card> 156 + <h2 className="text-xl font-bold text-gray-800 mb-4">API Test</h2> 157 + <form onSubmit={handleSubmit} className="space-y-4"> 158 + <div className="flex flex-col sm:flex-row gap-2"> 159 + <select 160 + value={method} 161 + onChange={(e) => setMethod(e.target.value)} 162 + className="px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" 152 163 > 153 - Invite Code 154 - </label> 164 + <option>GET</option> 165 + <option>POST</option> 166 + </select> 155 167 <input 156 168 type="text" 157 - id="invite" 158 - placeholder="xxxx-xxxx-xxxx-xxxx" 159 - className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" 169 + value={endpoint} 170 + onChange={(e) => setEndpoint(e.target.value)} 171 + placeholder="/xrpc/endpoint.name" 172 + className="flex-grow block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" 173 + required 160 174 /> 161 175 </div> 176 + {method === "POST" && ( 177 + <div> 178 + <label 179 + htmlFor="body" 180 + className="block text-sm font-medium text-gray-600" 181 + > 182 + Request Body (JSON) 183 + </label> 184 + <textarea 185 + id="body" 186 + rows={3} 187 + value={body} 188 + onChange={(e) => setBody(e.target.value)} 189 + placeholder='{ "key": "value" }' 190 + className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono text-sm" 191 + /> 192 + </div> 193 + )} 162 194 <button 163 195 type="submit" 196 + disabled={isLoading} 164 197 className="w-full px-4 py-2 rounded-md bg-blue-600 text-white font-semibold shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:bg-gray-400" 165 198 > 166 - Create Account 199 + {isLoading ? "Sending..." : "Send Request"} 167 200 </button> 168 - </fieldset> 169 - </div> 201 + </form> 202 + <div className="mt-6"> 203 + <h3 className="text-lg font-semibold text-gray-700">Response</h3> 204 + {error && ( 205 + <pre className="mt-2 p-3 bg-red-50 text-red-700 text-xs rounded-md overflow-x-auto"> 206 + Error: {error} 207 + </pre> 208 + )} 209 + {response && ( 210 + <pre className="mt-2 p-3 bg-gray-800 text-white text-xs rounded-md overflow-x-auto"> 211 + {response} 212 + </pre> 213 + )} 214 + {!isLoading && !error && !response && ( 215 + <p className="text-gray-500 mt-2 text-sm"> 216 + Response will appear here. 217 + </p> 218 + )} 219 + </div> 220 + </Card> 170 221 ); 171 222 } 172 223 173 - export function App({ type }: { type: "index" | "view" }) { 174 - const [loggedIn, setLoggedIn] = useState(false); 175 - const capitaltitle = type === "index" ? "Index" : "View"; 224 + function SocialAppButton({ did }: { did?: string }) { 225 + return ( 226 + <Card className="text-center gap-2 flex flex-col"> 227 + <h2 className="text-xl font-semibold text-gray-700"> 228 + Explore the Network 229 + </h2> 230 + <p className="text-sm text-gray-600 space-y-3"> 231 + Use the hosted client to browse this View Server. 232 + </p> 233 + <a 234 + href={instanceConfig.socialAppUrl} 235 + target="_blank" 236 + rel="noopener noreferrer" 237 + className="w-full px-4 py-2 rounded-md bg-blue-600 text-white font-semibold shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:bg-gray-400" 238 + > 239 + Open Social App 240 + </a> 241 + <p className="text-sm text-gray-600 space-y-3"> 242 + Or use any other #bsky_appview compatible client. 243 + </p> 244 + <pre className="text-sm bg-gray-100 py-2 px-3 rounded-sm overflow-y-auto"> 245 + {did}#bsky_appview 246 + </pre> 247 + <p className="text-sm text-gray-600 space-y-3 italic"> 248 + (works best for registered users) 249 + </p> 250 + </Card> 251 + ); 252 + } 176 253 254 + function InstanceInfo({ 255 + config, 256 + configloading, 257 + }: { 258 + config: 259 + | { 260 + inviteOnly: boolean; 261 + //port: number; 262 + did: string; 263 + host: string; 264 + indexPriority?: string[]; 265 + } 266 + | undefined; 267 + configloading: boolean; 268 + }) { 177 269 return ( 178 - <div className="min-h-screen flex items-center justify-center bg-gray-100 font-sans p-4"> 179 - <div className="w-full max-w-4xl mx-auto bg-white shadow-2xl rounded-2xl p-8 space-y-8 flex flex-col md:flex-row md:space-y-0 md:space-x-12"> 180 - <div className="flex-1"> 181 - <header className="mb-8"> 182 - <h1 className="text-4xl font-bold text-blue-600"> 183 - Skylite {capitaltitle} Server 184 - </h1> 185 - <p className="text-gray-500"> 186 - A lightweight {capitaltitle} server for the Bluesky network.{`\n`}( 187 - {window.location.hostname}) 188 - </p> 189 - </header> 190 - <UserList users={dummyUsers} /> 270 + <> 271 + <h2 className="text-xl font-semibold text-gray-700 mb-2"> 272 + About <span className="">{instanceConfig.name}</span> ( 273 + {window.location.hostname}) 274 + </h2> 275 + <p className="text-gray-600">{instanceConfig.description}</p> 276 + {config && ( 277 + <div className="bg-gray-100 rounded-xl p-4"> 278 + <p> 279 + <strong>DID:</strong> {config.did} 280 + </p> 281 + {/* <p> 282 + <strong>Port:</strong> {config.port} 283 + </p> */} 284 + <p> 285 + <strong>Host:</strong> {config.host} 286 + </p> 287 + <p> 288 + <strong>Invite only:</strong> {config.inviteOnly ? "Yes" : "No"} 289 + </p> 290 + 291 + {config.indexPriority && ( 292 + <div> 293 + <strong>Index Priority:</strong> 294 + <ol className="list-decimal list-inside ml-4"> 295 + {config.indexPriority.map((priority, idx) => ( 296 + <li key={idx}>{priority}</li> 297 + ))} 298 + </ol> 299 + </div> 300 + )} 191 301 </div> 302 + )} 303 + </> 304 + ); 305 + } 306 + function APIStatus() { 307 + const [results, setResults] = useState<Record<string, string> | undefined>(); 308 + const [loading, setLoading] = useState(true); 309 + 310 + useEffect(() => { 311 + async function fetchResults() { 312 + try { 313 + const response = await fetch("/_unspecced/apitest"); 314 + if (!response.ok) throw new Error("Failed to fetch API test results"); 315 + const data = await response.json(); 316 + setResults(data); 317 + } catch (error) { 318 + console.error("Error fetching API test:", error); 319 + } finally { 320 + setLoading(false); 321 + } 322 + } 323 + fetchResults(); 324 + }, []); 325 + 326 + if (loading) return <p>Loading API test...</p>; 327 + if (!results) return <p>Failed to load API test results.</p>; 328 + 329 + const colorMap: Record<string, string> = { 330 + green: "bg-green-500", 331 + orange: "bg-orange-400", 332 + red: "bg-red-500", 333 + black: "bg-gray-700", 334 + }; 335 + 336 + const statuses = Object.values(results); 337 + const sortedStatuses = [...statuses].sort( 338 + (a, b) => 339 + ["green", "orange", "red", "black"].indexOf(a) - 340 + ["green", "orange", "red", "black"].indexOf(b) 341 + ); 192 342 193 - <div className="flex-1"> 194 - <div className="flex justify-between items-center mb-6"> 195 - <h2 className="text-2xl font-bold text-gray-800"> 196 - {loggedIn ? "Welcome!" : "Get Started"} 197 - </h2> 198 - <Login compact /> 199 - {/* <button 200 - type="button" 201 - onClick={() => setLoggedIn(!loggedIn)} 202 - className="px-5 py-2 rounded-lg font-semibold text-white transition-colors duration-200 bg-blue-500 hover:bg-blue-600" 203 - > 204 - {loggedIn ? "Logout" : "Login"} 205 - </button> */} 343 + const categories: Record<string, Record<string, string>> = {}; 344 + for (const [rawKey, status] of Object.entries(results)) { 345 + const [category, route] = rawKey.includes(":") 346 + ? rawKey.split(/:(.+)/) 347 + : ["uncategorized", rawKey]; 348 + 349 + if (!categories[category]) categories[category] = {}; 350 + categories[category][route] = status; 351 + } 352 + 353 + return ( 354 + <> 355 + <h2 className="text-xl font-semibold text-gray-700 mb-2">API Status</h2> 356 + <div className="flex flex-row gap-4"> 357 + <div className="bg-gray-100 rounded-xl p-4 mb-4"> 358 + <h3 className="font-semibold text-gray-700 mb-2">Legend</h3> 359 + <ul className="space-y-1"> 360 + <li className="flex items-center space-x-2"> 361 + <span 362 + className={`w-3 h-3 rounded-full ${colorMap["green"]}`} 363 + ></span> 364 + <span>= done</span> 365 + </li> 366 + <li className="flex items-center space-x-2"> 367 + <span 368 + className={`w-3 h-3 rounded-full ${colorMap["orange"]}`} 369 + ></span> 370 + <span>= half-done</span> 371 + </li> 372 + <li className="flex items-center space-x-2"> 373 + <span 374 + className={`w-3 h-3 rounded-full ${colorMap["red"]}`} 375 + ></span> 376 + <span>= actively wrong</span> 377 + </li> 378 + <li className="flex items-center space-x-2"> 379 + <span 380 + className={`w-3 h-3 rounded-full ${colorMap["black"]}`} 381 + ></span> 382 + <span>= not there</span> 383 + </li> 384 + </ul> 385 + </div> 386 + <div className="bg-gray-100 rounded-xl p-4 space-y-4 mb-4 flex-1"> 387 + <h3 className="font-semibold text-gray-700 mb-2">Overview</h3> 388 + <div className="flex flex-wrap gap-2"> 389 + {sortedStatuses.map((status, idx) => ( 390 + <div 391 + key={idx} 392 + className={`w-4 h-4 rounded-full ${colorMap[status]}`} 393 + title={status} 394 + ></div> 395 + ))} 206 396 </div> 207 - <div className="w-full h-px bg-gray-200 my-6"></div> 208 - <RegistrationForm isLoggedIn={loggedIn} /> 209 397 </div> 210 398 </div> 399 + 400 + <div className="bg-gray-100 rounded-xl p-4 space-y-4"> 401 + <div className="space-y-4"> 402 + {Object.entries(categories).map(([category, routes]) => ( 403 + <div key={category}> 404 + <h3 className="font-semibold text-lg text-gray-700"> 405 + {category} 406 + </h3> 407 + <ul className="ml-4 space-y-1"> 408 + {Object.entries(routes).map(([route, status]) => ( 409 + <li key={route} className="flex items-center space-x-2"> 410 + <span 411 + className={`w-3 h-3 rounded-full ${ 412 + colorMap[status] ?? "bg-gray-400" 413 + }`} 414 + ></span> 415 + <span className="font-mono">{route}</span> 416 + </li> 417 + ))} 418 + </ul> 419 + </div> 420 + ))} 421 + </div> 422 + </div> 423 + </> 424 + ); 425 + } 426 + 427 + function AboutSkylite({ type }: { type: "index" | "view" }) { 428 + const isIndex = type === "index"; 429 + return ( 430 + <div className="gap-2 flex flex-col"> 431 + <h2 className="text-xl font-semibold text-gray-700"> 432 + What is a Skylite {isIndex ? "Index Server" : "View Server"}? 433 + </h2> 434 + <div className="text-sm text-gray-600 space-y-3"> 435 + <p> 436 + {isIndex 437 + ? `An Index Server is where your social data lives on the Bluesky network. It stores all your network activity, including posts, replies, likes, and followers. The scoped nature of Index Servers makes them easier to host and manage, while also strengthening the overall resilience of the Bluesky network.` 438 + : `A View Server is a service that presents data from multiple collections (indexes) across the network. It enhances public data with automatic resolving, caches, moderation, following feeds, and notifications, providing a unified "view" for client applications.`} 439 + </p> 440 + <p> 441 + {isIndex 442 + ? `Want to use this data? Explore Skylite View Servers to get started.` 443 + : `Skylite View Servers collect decentralized indexed data from multiple Index Servers (and AppViews too!), giving clients a broader and more resilient view of the network.`} 444 + </p> 445 + </div> 211 446 </div> 212 447 ); 213 - } 448 + } 449 + 450 + function UserList({ 451 + users, 452 + isLoading, 453 + }: { 454 + users: { 455 + did: string; 456 + role: string; 457 + registrationdate: string; 458 + onboardingstatus: string; 459 + pfp?: string; 460 + displayname: string; 461 + handle: string; 462 + }[]; 463 + isLoading: boolean; 464 + }) { 465 + return ( 466 + <> 467 + <h2 className="text-xl font-semibold text-gray-700 mb-4"> 468 + Registered Users ({users.length}) 469 + </h2> 470 + <div className="space-y-4 max-h-[300px] overflow-y-auto border p-2 rounded-md"> 471 + {isLoading ? ( 472 + <p className="text-gray-500 p-2">Loading users...</p> 473 + ) : users.length === 0 ? ( 474 + <p className="text-gray-500 p-2">No users have registered yet.</p> 475 + ) : ( 476 + users.map((user) => ( 477 + <div key={user.did} className="flex items-center space-x-4"> 478 + <img 479 + src={user.pfp} 480 + alt={user.did} 481 + className="w-12 h-12 rounded-full bg-gray-200" 482 + /> 483 + <div> 484 + <p className="font-bold text-gray-800">{user.displayname}</p> 485 + <p className="text-sm text-gray-500">@{user.handle}</p> 486 + <p className="text-sm text-gray-500"> 487 + {user.role} - {user.onboardingstatus} 488 + </p> 489 + {/* <p className="text-sm text-gray-500">{user.onboardingstatus}</p> */} 490 + </div> 491 + </div> 492 + )) 493 + )} 494 + </div> 495 + </> 496 + ); 497 + } 498 + 499 + function RegistrationForm({ 500 + isLoggedIn, 501 + agent, 502 + inviteRequired, 503 + }: { 504 + isLoggedIn: boolean; 505 + agent: ATPAPI.BskyAgent | null; 506 + inviteRequired: boolean; 507 + }) { 508 + const [inviteCode, setInviteCode] = useState(""); 509 + const [error, setError] = useState(""); 510 + const [success, setSuccess] = useState(""); 511 + const [isSubmitting, setIsSubmitting] = useState(false); 512 + 513 + const handleSubmit = async (e: React.FormEvent) => { 514 + e.preventDefault(); 515 + if (!agent || !isLoggedIn) { 516 + setError("You must be logged in to register."); 517 + return; 518 + } 519 + setError(""); 520 + setSuccess(""); 521 + setIsSubmitting(true); 522 + // TODO implement registration logic 523 + setTimeout(() => { 524 + setError("Registration endpoint not implemented."); 525 + setIsSubmitting(false); 526 + }, 1000); 527 + }; 528 + 529 + return ( 530 + <> 531 + <h2 className="text-xl font-semibold text-gray-700 mb-4">Register</h2> 532 + <p className="text-sm text-gray-500 mb-4"> 533 + You must be logged in {inviteRequired && "with an invite code"} to 534 + register on this server. 535 + </p> 536 + <form onSubmit={handleSubmit}> 537 + <fieldset 538 + disabled={!isLoggedIn || isSubmitting} 539 + className="space-y-4 disabled:opacity-50" 540 + > 541 + {inviteRequired && ( 542 + <div> 543 + <label 544 + htmlFor="invite" 545 + className="block text-sm font-medium text-gray-600" 546 + > 547 + Invite Code 548 + </label> 549 + <input 550 + type="text" 551 + id="invite" 552 + value={inviteCode} 553 + onChange={(e) => setInviteCode(e.target.value)} 554 + placeholder="xxxx-xxxx-xxxx-xxxx" 555 + className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" 556 + required 557 + /> 558 + </div> 559 + )} 560 + <button 561 + type="submit" 562 + className="w-full px-4 py-2 rounded-md bg-blue-600 text-white font-semibold shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:bg-gray-400" 563 + > 564 + {isSubmitting ? "Registering..." : "Register Account"} 565 + </button> 566 + {error && <p className="text-sm text-red-600 mt-2">{error}</p>} 567 + {success && <p className="text-sm text-green-600 mt-2">{success}</p>} 568 + </fieldset> 569 + </form> 570 + </> 571 + ); 572 + } 573 + 574 + export function App({ type }: { type: "index" | "view" }) { 575 + const { agent, loginStatus, loading } = useAuth(); 576 + const [users, setUsers] = useState< 577 + { 578 + did: string; 579 + role: string; 580 + registrationdate: string; 581 + onboardingstatus: string; 582 + pfp?: string; 583 + displayname: string; 584 + handle: string; 585 + }[] 586 + >([]); 587 + const [usersLoading, setUsersLoading] = useState(true); 588 + 589 + const isIndex = type === "index"; 590 + const capitaltitle = isIndex ? "Index" : "View"; 591 + const isLoggedIn: boolean = !!(loginStatus && agent?.did && !loading); 592 + const [config, putConfig] = useState< 593 + | { 594 + inviteOnly: boolean; 595 + //port: number; 596 + did: string; 597 + host: string; 598 + indexPriority?: string[]; 599 + } 600 + | undefined 601 + >(); 602 + const [configloading, setconfigloading] = useState(true); 603 + useEffect(() => { 604 + async function fetchConfig() { 605 + try { 606 + const response = await fetch("/_unspecced/config"); 607 + if (!response.ok) throw new Error("Failed to fetch user list"); 608 + const data = await response.json(); 609 + console.log(data); 610 + putConfig(data); 611 + } catch (error) { 612 + console.error("Error fetching config:", error); 613 + } finally { 614 + setconfigloading(false); 615 + } 616 + } 617 + fetchConfig(); 618 + }, []); 619 + 620 + useEffect(() => { 621 + async function fetchUsers() { 622 + try { 623 + const response = await fetch("/_unspecced/users"); 624 + if (!response.ok) throw new Error("Failed to fetch user list"); 625 + const data = await response.json(); 626 + setUsers(data); 627 + } catch (error) { 628 + console.error("Error fetching users:", error); 629 + } finally { 630 + setUsersLoading(false); 631 + } 632 + } 633 + fetchUsers(); 634 + }, []); 635 + 636 + const instancehost = config?.did 637 + ? (config?.did).slice("did:web:".length) 638 + : undefined; 639 + 640 + return ( 641 + <div className="bg-gray-100 min-h-screen font-sans"> 642 + <Header 643 + isLoggedIn={isLoggedIn} 644 + agent={agent ?? undefined} 645 + isIndex={isIndex} 646 + capitaltitle={capitaltitle} 647 + instancehost={instancehost} 648 + /> 649 + 650 + <main className="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8"> 651 + <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start"> 652 + <div className="lg:col-span-2 space-y-8"> 653 + <Card> 654 + <InstanceInfo config={config} configloading={configloading} /> 655 + </Card> 656 + <Card> 657 + <UserList users={users} isLoading={usersLoading} /> 658 + </Card> 659 + {isLoggedIn && <DataManager agent={agent ?? undefined} />} 660 + <Card> 661 + <APIStatus /> 662 + </Card> 663 + </div> 664 + 665 + <div className="space-y-8"> 666 + <Card> 667 + <RegistrationForm 668 + isLoggedIn={isLoggedIn} 669 + agent={agent} 670 + inviteRequired={config?.inviteOnly ?? false} 671 + /> 672 + </Card> 673 + <Card> 674 + <AboutSkylite type={type} /> 675 + </Card> 676 + {isIndex && <ApiTester />} 677 + {!isIndex && <SocialAppButton did={config?.did} />} 678 + </div> 679 + </div> 680 + </main> 681 + 682 + <footer className="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 text-xs text-gray-500 border-t border-gray-200 mt-8 flex justify-end gap-4"> 683 + <a 684 + href={instanceConfig.repoUrl} 685 + target="_blank" 686 + rel="noopener noreferrer" 687 + className="hover:underline" 688 + > 689 + Skylite Git Repo 690 + </a> 691 + <span>Icons by Font Awesome (CC BY 4.0)</span> 692 + </footer> 693 + </div> 694 + ); 695 + }
+29 -12
shared-landing/build.ts
··· 6 6 import postcss from "https://esm.sh/postcss@8"; 7 7 import autoprefixer from "https://esm.sh/autoprefixer@10"; 8 8 9 - const rawcss = await Deno.readTextFile("./shared-landing/app.css"); 10 - 11 - // @ts-ignore its fiiine 12 - const cssResult = await postcss([ 13 - tailwindcss(tailwindconfig), 14 - autoprefixer(), 15 - ]).process(rawcss, { 16 - from: "./shared-landing/app.css", 17 - map: false, 18 - }); 19 - 20 9 // helper build function 21 10 async function build(entry: string) { 22 11 const template = await Deno.readTextFile( ··· 48 37 runtimeModulePath: "https://esm.sh/react@19.1.1/jsx-runtime", 49 38 }), 50 39 ], 40 + }); 41 + const rawcss = await Deno.readTextFile("./shared-landing/app.css"); 42 + 43 + // @ts-ignore its fiiine 44 + const cssResult = await postcss([ 45 + tailwindcss(tailwindconfig), 46 + autoprefixer(), 47 + ]).process(rawcss, { 48 + from: "./shared-landing/app.css", 49 + map: false, 51 50 }); 52 51 53 52 const js = result.outputFiles[0].text; 53 + 54 + const jshash = hashString(js); 55 + const csshash = hashString(cssResult.css); 54 56 const html = template.replace( 55 57 "<!--SCRIPT-INJECT-->", 56 - `<script type="module" src="/landing-${entry}.js"></script>` 58 + `<script type="module" src="/landing-${entry}.js?v=${jshash}"></script> 59 + <link href="./app.css?v=${csshash}" rel="stylesheet">` 57 60 ); 58 61 59 62 return { js, html, css: cssResult.css }; ··· 77 80 } 78 81 } 79 82 } 83 + 84 + async function hashString(content: string): Promise<string> { 85 + const encoder = new TextEncoder(); 86 + const data = encoder.encode(content); 87 + 88 + // SHA-256 hash 89 + const hashBuffer = await crypto.subtle.digest("SHA-256", data); 90 + 91 + // Convert buffer to hex string 92 + return Array.from(new Uint8Array(hashBuffer)) 93 + .map(b => b.toString(16).padStart(2, "0")) 94 + .join("") 95 + .slice(0, 8); // optional: shorten hash for filenames 96 + }
+2 -2
shared-landing/template-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 + <link rel="icon" href="/public/index.ico" /> 6 7 <title>Skylite Index Server</title> 7 8 <!-- <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> --> 8 9 <!--SCRIPT-INJECT--> 9 - <link href="./app.css" rel="stylesheet"> 10 10 </head> 11 11 <body class="bg-gray-100 min-h-screen flex items-center justify-center"> 12 - <div id="root"></div> 12 + <div class="flex flex-1 flex-col" id="root"></div> 13 13 </body> 14 14 </html>
+2 -2
shared-landing/template-view.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 6 + <link rel="icon" href="/public/view.ico" /> 6 7 <title>Skylite View Server</title> 7 8 <!-- <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> --> 8 9 <!--SCRIPT-INJECT--> 9 - <link href="./app.css" rel="stylesheet"> 10 10 </head> 11 11 <body class="bg-gray-100 min-h-screen flex items-center justify-center"> 12 - <div id="root"></div> 12 + <div class="flex flex-1 flex-col" id="root"></div> 13 13 </body> 14 14 </html>
+33
viewserver.ts
··· 89 89 console.log("viewServer started."); 90 90 } 91 91 92 + async unspeccedGetRegisteredUsers(): Promise<{ 93 + did: string; 94 + role: string; 95 + registrationdate: string; 96 + onboardingstatus: string; 97 + pfp?: string; 98 + displayname: string; 99 + handle: string; 100 + }[]|undefined> { 101 + const stmt = this.systemDB.prepare(` 102 + SELECT * 103 + FROM users; 104 + `); 105 + const result = stmt.all() as 106 + { 107 + did: string; 108 + role: string; 109 + registrationdate: string; 110 + onboardingstatus: string; 111 + }[]; 112 + const hydrated = await Promise.all( result.map(async (user)=>{ 113 + const identity = await resolveIdentity(user.did); 114 + const profile = (await getSlingshotRecord(identity.did,"app.bsky.actor.profile","self")).value as ATPAPI.AppBskyActorProfile.Record; 115 + const avatarcid = uncid(profile.avatar?.ref); 116 + const avatar = avatarcid 117 + ? buildBlobUrl(identity.pds, identity.did, avatarcid) 118 + : undefined; 119 + return {...user,handle: identity.handle,pfp: avatar, displayname:profile.displayName ?? identity.handle } 120 + })) 121 + //const exists = result !== undefined; 122 + return hydrated; 123 + } 124 + 92 125 async viewServerHandler(req: Request): Promise<Response> { 93 126 const url = new URL(req.url); 94 127 const pathname = url.pathname;