an attempt to make a lightweight, easily self-hostable, scoped bluesky appview
1import React, {
2 useState,
3 useEffect,
4 PropsWithChildren,
5} from "https://esm.sh/react@19.1.1";
6import {
7 createRoot,
8 hydrateRoot,
9} from "https://esm.sh/react-dom@19.1.1/client";
10import * as ATPAPI from "https://esm.sh/@atproto/api";
11import { AuthProvider, useAuth } from "./passauthprovider.tsx";
12import Login from "./passlogin.tsx";
13import { Fa7RegularContactBook, Fa7RegularMap } from "./icons.tsx";
14import {
15 boolean,
16 number,
17 string,
18} from "https://esm.sh/zod@3.25.76/index.d.cts";
19console.log("script loaded");
20
21// TODO it should read from the config.jsonc instead
22const 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};
29
30const 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);
38
39function Header({
40 isLoggedIn,
41 agent,
42 isIndex,
43 capitaltitle,
44 instancehost,
45}: {
46 isLoggedIn: boolean;
47 agent?: ATPAPI.AtpAgent;
48 isIndex: boolean;
49 capitaltitle: string;
50 instancehost?: string;
51}) {
52 const userHandle = agent?.session?.handle;
53 return (
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>
70 </div>
71 </div>
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
86function 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>
110 </div>
111 </Card>
112 );
113}
114
115function 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
154 return (
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"
163 >
164 <option>GET</option>
165 <option>POST</option>
166 </select>
167 <input
168 type="text"
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
174 />
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 )}
194 <button
195 type="submit"
196 disabled={isLoading}
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"
198 >
199 {isLoading ? "Sending..." : "Send Request"}
200 </button>
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>
221 );
222}
223
224function 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}
253
254function 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}) {
269 return (
270 <>
271 <h2 className="text-xl font-semibold text-gray-700 mb-2">
272 About <span className="">{instanceConfig.name}</span> ({config?.host})
273 </h2>
274 <p className="text-gray-600">{instanceConfig.description}</p>
275 {config && (
276 <div className="bg-gray-100 rounded-xl p-4">
277 <p>
278 <strong>DID:</strong> {config.did}
279 </p>
280 {/* <p>
281 <strong>Port:</strong> {config.port}
282 </p> */}
283 <p>
284 <strong>Host:</strong> {config.host}
285 </p>
286 <p>
287 <strong>Invite only:</strong> {config.inviteOnly ? "Yes" : "No"}
288 </p>
289
290 {config.indexPriority && (
291 <div>
292 <strong>Index Priority:</strong>
293 <ol className="list-decimal list-inside ml-4">
294 {config.indexPriority.map((priority, idx) => (
295 <li key={idx}>{priority}</li>
296 ))}
297 </ol>
298 </div>
299 )}
300 </div>
301 )}
302 </>
303 );
304}
305function APIStatus() {
306 const [results, setResults] = useState<Record<string, string> | undefined>();
307 const [loading, setLoading] = useState(true);
308
309 useEffect(() => {
310 async function fetchResults() {
311 try {
312 const response = await fetch("/_unspecced/apitest");
313 if (!response.ok) throw new Error("Failed to fetch API test results");
314 const data = await response.json();
315 setResults(data);
316 } catch (error) {
317 console.error("Error fetching API test:", error);
318 } finally {
319 setLoading(false);
320 }
321 }
322 fetchResults();
323 }, []);
324
325 if (loading) return <p>Loading API test...</p>;
326 if (!results) return <p>Failed to load API test results.</p>;
327
328 const colorMap: Record<string, string> = {
329 green: "bg-green-500",
330 orange: "bg-orange-400",
331 red: "bg-red-500",
332 black: "bg-gray-700",
333 };
334
335 const statuses = Object.values(results);
336 const sortedStatuses = [...statuses].sort(
337 (a, b) =>
338 ["green", "orange", "red", "black"].indexOf(a) -
339 ["green", "orange", "red", "black"].indexOf(b)
340 );
341
342 const categories: Record<string, Record<string, string>> = {};
343 for (const [rawKey, status] of Object.entries(results)) {
344 const [category, route] = rawKey.includes(":")
345 ? rawKey.split(/:(.+)/)
346 : ["uncategorized", rawKey];
347
348 if (!categories[category]) categories[category] = {};
349 categories[category][route] = status;
350 }
351
352 return (
353 <>
354 <h2 className="text-xl font-semibold text-gray-700 mb-2">API Status</h2>
355 <div className="flex flex-row gap-4">
356 <div className="bg-gray-100 rounded-xl p-4 mb-4">
357 <h3 className="font-semibold text-gray-700 mb-2">Legend</h3>
358 <ul className="space-y-1">
359 <li className="flex items-center space-x-2">
360 <span
361 className={`w-3 h-3 rounded-full ${colorMap["green"]}`}
362 ></span>
363 <span>= done</span>
364 </li>
365 <li className="flex items-center space-x-2">
366 <span
367 className={`w-3 h-3 rounded-full ${colorMap["orange"]}`}
368 ></span>
369 <span>= half-done</span>
370 </li>
371 <li className="flex items-center space-x-2">
372 <span
373 className={`w-3 h-3 rounded-full ${colorMap["red"]}`}
374 ></span>
375 <span>= actively wrong</span>
376 </li>
377 <li className="flex items-center space-x-2">
378 <span
379 className={`w-3 h-3 rounded-full ${colorMap["black"]}`}
380 ></span>
381 <span>= not there</span>
382 </li>
383 </ul>
384 </div>
385 <div className="bg-gray-100 rounded-xl p-4 space-y-4 mb-4 flex-1">
386 <h3 className="font-semibold text-gray-700 mb-2">Overview</h3>
387 <div className="flex flex-wrap gap-2">
388 {sortedStatuses.map((status, idx) => (
389 <div
390 key={idx}
391 className={`w-4 h-4 rounded-full ${colorMap[status]}`}
392 title={status}
393 ></div>
394 ))}
395 </div>
396 </div>
397 </div>
398
399 <div className="bg-gray-100 rounded-xl p-4 space-y-4">
400 <div className="space-y-4">
401 {Object.entries(categories).map(([category, routes]) => (
402 <div key={category}>
403 <h3 className="font-semibold text-lg text-gray-700">
404 {category}
405 </h3>
406 <ul className="ml-4 space-y-1">
407 {Object.entries(routes).map(([route, status]) => (
408 <li key={route} className="flex items-center space-x-2">
409 <span
410 className={`w-3 h-3 rounded-full ${
411 colorMap[status] ?? "bg-gray-400"
412 }`}
413 ></span>
414 <span className="font-mono">{route}</span>
415 </li>
416 ))}
417 </ul>
418 </div>
419 ))}
420 </div>
421 </div>
422 </>
423 );
424}
425
426function AboutSkylite({ type }: { type: "index" | "view" }) {
427 const isIndex = type === "index";
428 return (
429 <div className="gap-2 flex flex-col">
430 <h2 className="text-xl font-semibold text-gray-700">
431 What is a Skylite {isIndex ? "Index Server" : "View Server"}?
432 </h2>
433 <div className="text-sm text-gray-600 space-y-3">
434 <p>
435 {isIndex
436 ? `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.`
437 : `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.`}
438 </p>
439 <p>
440 {isIndex
441 ? `Want to use this data? Explore Skylite View Servers to get started.`
442 : `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.`}
443 </p>
444 </div>
445 </div>
446 );
447}
448
449function UserList({
450 users,
451 isLoading,
452}: {
453 users: {
454 did: string;
455 role: string;
456 registrationdate: string;
457 onboardingstatus: string;
458 pfp?: string;
459 displayname: string;
460 handle: string;
461 }[];
462 isLoading: boolean;
463}) {
464 return (
465 <>
466 <h2 className="text-xl font-semibold text-gray-700 mb-4">
467 Registered Users ({users.length})
468 </h2>
469 <div className="space-y-4 max-h-[300px] overflow-y-auto border p-2 rounded-md">
470 {isLoading ? (
471 <p className="text-gray-500 p-2">Loading users...</p>
472 ) : users.length === 0 ? (
473 <p className="text-gray-500 p-2">No users have registered yet.</p>
474 ) : (
475 users.map((user) => (
476 <div key={user.did} className="flex items-center space-x-4">
477 <img
478 src={user.pfp}
479 alt={user.did}
480 className="w-12 h-12 rounded-full bg-gray-200"
481 />
482 <div>
483 <p className="font-bold text-gray-800">{user.displayname}</p>
484 <p className="text-sm text-gray-500">@{user.handle}</p>
485 <p className="text-sm text-gray-500">
486 {user.role} - {user.onboardingstatus}
487 </p>
488 {/* <p className="text-sm text-gray-500">{user.onboardingstatus}</p> */}
489 </div>
490 </div>
491 ))
492 )}
493 </div>
494 </>
495 );
496}
497
498function RegistrationForm({
499 isLoggedIn,
500 agent,
501 inviteRequired,
502}: {
503 isLoggedIn: boolean;
504 agent: ATPAPI.BskyAgent | null;
505 inviteRequired: boolean;
506}) {
507 const [inviteCode, setInviteCode] = useState("");
508 const [error, setError] = useState("");
509 const [success, setSuccess] = useState("");
510 const [isSubmitting, setIsSubmitting] = useState(false);
511
512 const handleSubmit = async (e: React.FormEvent) => {
513 e.preventDefault();
514 if (!agent || !isLoggedIn) {
515 setError("You must be logged in to register.");
516 return;
517 }
518 setError("");
519 setSuccess("");
520 setIsSubmitting(true);
521 // TODO implement registration logic
522 setTimeout(() => {
523 setError("Registration endpoint not implemented.");
524 setIsSubmitting(false);
525 }, 1000);
526 };
527
528 return (
529 <>
530 <h2 className="text-xl font-semibold text-gray-700 mb-4">Register</h2>
531 <p className="text-sm text-gray-500 mb-4">
532 You must be logged in {inviteRequired && "with an invite code"} to
533 register on this server.
534 </p>
535 <form onSubmit={handleSubmit}>
536 <fieldset
537 disabled={!isLoggedIn || isSubmitting}
538 className="space-y-4 disabled:opacity-50"
539 >
540 {inviteRequired && (
541 <div>
542 <label
543 htmlFor="invite"
544 className="block text-sm font-medium text-gray-600"
545 >
546 Invite Code
547 </label>
548 <input
549 type="text"
550 id="invite"
551 value={inviteCode}
552 onChange={(e) => setInviteCode(e.target.value)}
553 placeholder="xxxx-xxxx-xxxx-xxxx"
554 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"
555 required
556 />
557 </div>
558 )}
559 <button
560 type="submit"
561 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"
562 >
563 {isSubmitting ? "Registering..." : "Register Account"}
564 </button>
565 {error && <p className="text-sm text-red-600 mt-2">{error}</p>}
566 {success && <p className="text-sm text-green-600 mt-2">{success}</p>}
567 </fieldset>
568 </form>
569 </>
570 );
571}
572
573export function App({
574 type,
575 initialData,
576}: {
577 type: "index" | "view";
578 initialData?: {
579 config: {
580 inviteOnly: boolean;
581 //port: number;
582 did: string;
583 host: string;
584 indexPriority?: string[];
585 };
586 users: {
587 did: string;
588 role: string;
589 registrationdate: string;
590 onboardingstatus: string;
591 pfp?: string;
592 displayname: string;
593 handle: string;
594 }[];
595 };
596}) {
597 const { agent, loginStatus, loading } = useAuth();
598 console.log("hasinitialdata?",initialData ? true : false);
599 const [users, setUsers] = useState<
600 {
601 did: string;
602 role: string;
603 registrationdate: string;
604 onboardingstatus: string;
605 pfp?: string;
606 displayname: string;
607 handle: string;
608 }[]
609 >(initialData?.users ?? []);
610 const [usersLoading, setUsersLoading] = useState(!initialData?.users?.length);
611
612
613 const isIndex = type === "index";
614 const capitaltitle = isIndex ? "Index" : "View";
615 const isLoggedIn: boolean = !!(loginStatus && agent?.did && !loading);
616 const [config, putConfig] = useState<
617 | {
618 inviteOnly: boolean;
619 //port: number;
620 did: string;
621 host: string;
622 indexPriority?: string[];
623 }
624 | undefined
625 >(initialData?.config ?? undefined);
626 const [configloading, setconfigloading] = useState(!initialData?.config);
627 useEffect(() => {
628 if (config) return
629 async function fetchConfig() {
630 try {
631 const response = await fetch("/_unspecced/config");
632 if (!response.ok) throw new Error("Failed to fetch user list");
633 const data = await response.json();
634 console.log(data);
635 putConfig(data);
636 } catch (error) {
637 console.error("Error fetching config:", error);
638 } finally {
639 setconfigloading(false);
640 }
641 }
642 fetchConfig();
643 }, []);
644
645 useEffect(() => {
646 if (users.length) return
647 async function fetchUsers() {
648 try {
649 const response = await fetch("/_unspecced/users");
650 if (!response.ok) throw new Error("Failed to fetch user list");
651 const data = await response.json();
652 setUsers(data);
653 } catch (error) {
654 console.error("Error fetching users:", error);
655 } finally {
656 setUsersLoading(false);
657 }
658 }
659 fetchUsers();
660 }, []);
661
662 const instancehost = config?.did
663 ? (config?.did).slice("did:web:".length)
664 : undefined;
665
666 return (
667 <div className="bg-gray-100 min-h-screen font-sans">
668 <Header
669 isLoggedIn={isLoggedIn}
670 agent={agent ?? undefined}
671 isIndex={isIndex}
672 capitaltitle={capitaltitle}
673 instancehost={instancehost}
674 />
675
676 <main className="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
677 <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
678 <div className="lg:col-span-2 space-y-8">
679 <Card>
680 <InstanceInfo config={config} configloading={configloading} />
681 </Card>
682 <Card>
683 <UserList users={users} isLoading={usersLoading} />
684 </Card>
685 {isLoggedIn && <DataManager agent={agent ?? undefined} />}
686 <Card>
687 <APIStatus />
688 </Card>
689 </div>
690
691 <div className="space-y-8">
692 <Card>
693 <RegistrationForm
694 isLoggedIn={isLoggedIn}
695 agent={agent}
696 inviteRequired={config?.inviteOnly ?? false}
697 />
698 </Card>
699 <Card>
700 <AboutSkylite type={type} />
701 </Card>
702 {isIndex && <ApiTester />}
703 {!isIndex && <SocialAppButton did={config?.did} />}
704 </div>
705 </div>
706 </main>
707
708 <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">
709 <a
710 href={instanceConfig.repoUrl}
711 target="_blank"
712 rel="noopener noreferrer"
713 className="hover:underline"
714 >
715 Skylite Git Repo
716 </a>
717 <span>Icons by Font Awesome (CC BY 4.0)</span>
718 </footer>
719 </div>
720 );
721}
722
723export function Root({
724 type,
725 initialData,
726}: {
727 type: "index" | "view";
728 initialData?: {
729 config: {
730 inviteOnly: boolean;
731 //port: number;
732 did: string;
733 host: string;
734 indexPriority?: string[];
735 };
736 users: {
737 did: string;
738 role: string;
739 registrationdate: string;
740 onboardingstatus: string;
741 pfp?: string;
742 displayname: string;
743 handle: string;
744 }[];
745 };
746}) {
747 return (
748 <AuthProvider>
749 <App type={type} initialData={initialData} />
750 </AuthProvider>
751 );
752}