forked from pdsls.dev/pdsls
atproto explorer

add record deletion

+1
package.json
··· 19 }, 20 "dependencies": { 21 "@atcute/client": "^2.0.4", 22 "@solidjs/router": "^0.15.1", 23 "hls.js": "^1.5.17", 24 "public-transport": "file:pkg/pt.tgz",
··· 19 }, 20 "dependencies": { 21 "@atcute/client": "^2.0.4", 22 + "@atcute/oauth-browser-client": "^1.0.5", 23 "@solidjs/router": "^0.15.1", 24 "hls.js": "^1.5.17", 25 "public-transport": "file:pkg/pt.tgz",
+18
pnpm-lock.yaml
··· 11 '@atcute/client': 12 specifier: ^2.0.4 13 version: 2.0.4 14 '@solidjs/router': 15 specifier: ^0.15.1 16 version: 0.15.1(solid-js@1.9.3) ··· 60 61 '@atcute/client@2.0.4': 62 resolution: {integrity: sha512-bKA6KEOmrdhU2CDRNp13M4WyKN0EdrVLKJffzPo62ANSTMacz5hRJhmvQYwuo7BZSGIoDql4sH+QR6Xbk3DERg==} 63 64 '@babel/code-frame@7.26.2': 65 resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} ··· 914 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 915 hasBin: true 916 917 node-fetch-native@1.6.4: 918 resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} 919 ··· 1203 '@antfu/utils@0.7.10': {} 1204 1205 '@atcute/client@2.0.4': {} 1206 1207 '@babel/code-frame@7.26.2': 1208 dependencies: ··· 2030 ms@2.1.3: {} 2031 2032 nanoid@3.3.7: {} 2033 2034 node-fetch-native@1.6.4: {} 2035
··· 11 '@atcute/client': 12 specifier: ^2.0.4 13 version: 2.0.4 14 + '@atcute/oauth-browser-client': 15 + specifier: ^1.0.5 16 + version: 1.0.5 17 '@solidjs/router': 18 specifier: ^0.15.1 19 version: 0.15.1(solid-js@1.9.3) ··· 63 64 '@atcute/client@2.0.4': 65 resolution: {integrity: sha512-bKA6KEOmrdhU2CDRNp13M4WyKN0EdrVLKJffzPo62ANSTMacz5hRJhmvQYwuo7BZSGIoDql4sH+QR6Xbk3DERg==} 66 + 67 + '@atcute/oauth-browser-client@1.0.5': 68 + resolution: {integrity: sha512-UUs2WFMh22rXOapRM848WfWtvgaxV/ji0tEupFrrBYe2i+/UlwhXcphlqdwm43LBsFtMWtV1Xsy2zmnItf0Akg==} 69 70 '@babel/code-frame@7.26.2': 71 resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} ··· 920 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 921 hasBin: true 922 923 + nanoid@5.0.8: 924 + resolution: {integrity: sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==} 925 + engines: {node: ^18 || >=20} 926 + hasBin: true 927 + 928 node-fetch-native@1.6.4: 929 resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} 930 ··· 1214 '@antfu/utils@0.7.10': {} 1215 1216 '@atcute/client@2.0.4': {} 1217 + 1218 + '@atcute/oauth-browser-client@1.0.5': 1219 + dependencies: 1220 + '@atcute/client': 2.0.4 1221 + nanoid: 5.0.8 1222 1223 '@babel/code-frame@7.26.2': 1224 dependencies: ··· 2046 ms@2.1.3: {} 2047 2048 nanoid@3.3.7: {} 2049 + 2050 + nanoid@5.0.8: {} 2051 2052 node-fetch-native@1.6.4: {} 2053
+12
public/client-metadata.json
···
··· 1 + { 2 + "client_id": "https://pdsls.dev/client-metadata.json", 3 + "client_name": "pdsls", 4 + "client_uri": "https://pdsls.dev", 5 + "redirect_uris": ["https://pdsls.dev/"], 6 + "scope": "atproto transition:generic", 7 + "grant_types": ["authorization_code", "refresh_token"], 8 + "response_types": ["code"], 9 + "token_endpoint_auth_method": "none", 10 + "application_type": "web", 11 + "dpop_bound_access_tokens": true 12 + }
+92 -5
src/App.tsx src/main.tsx
··· 1 - import { createSignal, onMount, For, Show, type Component } from "solid-js"; 2 import { CredentialManager, XRPC } from "@atcute/client"; 3 import { 4 ComAtprotoRepoDescribeRepo, ··· 15 useLocation, 16 useParams, 17 } from "@solidjs/router"; 18 - import { JSONValue } from "./lib/json.jsx"; 19 import { 20 AiFillGithub, 21 Bluesky, ··· 23 BsClipboardCheck, 24 TbMoonStar, 25 TbSun, 26 - } from "./lib/svg.jsx"; 27 import { authenticate_post } from "public-transport"; 28 29 let rpc = new XRPC({ 30 handler: new CredentialManager({ service: "https://public.api.bsky.app" }), ··· 109 const RecordView: Component = () => { 110 const params = useParams(); 111 const [record, setRecord] = createSignal<ComAtprotoRepoGetRecord.Output>(); 112 113 onMount(async () => { 114 setNotice("Loading..."); 115 setPDS(params.pds); 116 let pds = ··· 131 } 132 }); 133 134 const getRecord = query( 135 (repo: string, collection: string, rkey: string) => 136 rpc.get("com.atproto.repo.getRecord", { ··· 139 "getRecord", 140 ); 141 142 return ( 143 <Show when={record()}> 144 <div class="overflow-y-auto pl-4"> 145 <JSONValue data={record() as any} repo={record()!.uri.split("/")[2]} /> 146 </div> ··· 387 setNotice(""); 388 389 return ( 390 - <div class="m-5 flex flex-col items-center text-slate-900 dark:text-slate-100"> 391 <div class="mb-2 flex w-[20rem] items-center"> 392 - <div class="basis-1/3"> 393 <div 394 class="w-fit cursor-pointer" 395 onclick={() => { ··· 404 <TbMoonStar class="size-6" /> 405 : <TbSun class="size-6" />} 406 </div> 407 </div> 408 <div class="basis-1/3 text-center font-mono text-xl font-bold"> 409 <A href="/" class="hover:underline"> ··· 422 </a> 423 </div> 424 </div> 425 <div class="mb-5 flex max-w-full flex-col items-center text-pretty lg:max-w-screen-lg"> 426 <form 427 class="flex flex-col items-center gap-y-1"
··· 1 + import { 2 + createSignal, 3 + onMount, 4 + For, 5 + Show, 6 + type Component, 7 + onCleanup, 8 + createEffect, 9 + } from "solid-js"; 10 import { CredentialManager, XRPC } from "@atcute/client"; 11 import { 12 ComAtprotoRepoDescribeRepo, ··· 23 useLocation, 24 useParams, 25 } from "@solidjs/router"; 26 + import { JSONValue } from "./components/json.jsx"; 27 import { 28 AiFillGithub, 29 Bluesky, ··· 31 BsClipboardCheck, 32 TbMoonStar, 33 TbSun, 34 + } from "./components/svg.jsx"; 35 import { authenticate_post } from "public-transport"; 36 + import { agent, loginState, LoginStatus } from "./components/login.jsx"; 37 38 let rpc = new XRPC({ 39 handler: new CredentialManager({ service: "https://public.api.bsky.app" }), ··· 118 const RecordView: Component = () => { 119 const params = useParams(); 120 const [record, setRecord] = createSignal<ComAtprotoRepoGetRecord.Output>(); 121 + const [modal, setModal] = createSignal<HTMLDialogElement>(); 122 + const [open, setOpen] = createSignal(false); 123 + 124 + let clickEvent = (event: MouseEvent) => { 125 + if (modal() && event.target == modal()) setOpen(false); 126 + }; 127 + let keyEvent = (event: KeyboardEvent) => { 128 + if (modal() && event.key == "Escape") setOpen(false); 129 + }; 130 131 onMount(async () => { 132 + window.addEventListener("click", clickEvent); 133 + window.addEventListener("keydown", keyEvent); 134 setNotice("Loading..."); 135 setPDS(params.pds); 136 let pds = ··· 151 } 152 }); 153 154 + onCleanup(() => { 155 + window.removeEventListener("click", clickEvent); 156 + window.removeEventListener("keydown", keyEvent); 157 + }); 158 + 159 const getRecord = query( 160 (repo: string, collection: string, rkey: string) => 161 rpc.get("com.atproto.repo.getRecord", { ··· 164 "getRecord", 165 ); 166 167 + const deleteRecord = action(async () => { 168 + rpc = new XRPC({ handler: agent }); 169 + rpc.call("com.atproto.repo.deleteRecord", { 170 + data: { 171 + repo: params.repo, 172 + collection: params.collection, 173 + rkey: params.rkey, 174 + }, 175 + }); 176 + throw redirect(`/at/${params.repo}/${params.collection}`); 177 + }); 178 + 179 + createEffect(() => { 180 + if (open()) document.body.style.overflow = "hidden"; 181 + else document.body.style.overflow = "auto"; 182 + }); 183 + 184 return ( 185 <Show when={record()}> 186 + <Show when={loginState() && agent.sub === params.repo}> 187 + <div class="flex w-full justify-center"> 188 + <Show when={open()}> 189 + <dialog 190 + ref={setModal} 191 + class="fixed left-0 top-0 z-[2] flex h-screen w-screen items-center justify-center bg-transparent font-sans" 192 + > 193 + <div class="dark:bg-dark-400 rounded-md border border-slate-900 bg-slate-100 p-4 text-slate-900 dark:border-slate-100 dark:text-slate-100"> 194 + <h3 class="text-lg font-bold">Delete this record?</h3> 195 + <form action={deleteRecord} method="post"> 196 + <div class="mt-2 inline-flex gap-2"> 197 + <button 198 + onclick={() => setOpen(false)} 199 + class="dark:bg-dark-900 dark:hover:bg-dark-800 rounded-lg bg-white px-2.5 py-1.5 text-sm font-bold hover:bg-slate-200 focus:outline-none focus:ring-2 focus:ring-slate-700 dark:focus:ring-slate-300" 200 + > 201 + Cancel 202 + </button> 203 + <button 204 + type="submit" 205 + class="rounded-lg bg-red-500 px-2.5 py-1.5 text-sm font-bold text-slate-100 hover:bg-red-400 focus:outline-none focus:ring-2 focus:ring-slate-700 dark:bg-red-600 dark:hover:bg-red-500 dark:focus:ring-slate-300" 206 + > 207 + Delete 208 + </button> 209 + </div> 210 + </form> 211 + </div> 212 + </dialog> 213 + </Show> 214 + <button 215 + onclick={() => setOpen(true)} 216 + class="rounded-lg bg-red-500 px-2.5 py-1.5 font-sans text-sm font-bold text-slate-100 hover:bg-red-400 focus:outline-none focus:ring-2 focus:ring-slate-700 dark:bg-red-600 dark:hover:bg-red-500 dark:focus:ring-slate-300" 217 + > 218 + Delete 219 + </button> 220 + </div> 221 + </Show> 222 <div class="overflow-y-auto pl-4"> 223 <JSONValue data={record() as any} repo={record()!.uri.split("/")[2]} /> 224 </div> ··· 465 setNotice(""); 466 467 return ( 468 + <div 469 + id="main" 470 + class="m-5 flex flex-col items-center text-slate-900 dark:text-slate-100" 471 + > 472 <div class="mb-2 flex w-[20rem] items-center"> 473 + <div class="flex basis-1/3 gap-x-2"> 474 <div 475 class="w-fit cursor-pointer" 476 onclick={() => { ··· 485 <TbMoonStar class="size-6" /> 486 : <TbSun class="size-6" />} 487 </div> 488 + <Show when={!loginState()}> 489 + <div> 490 + <A href="/login">Login</A> 491 + </div> 492 + </Show> 493 </div> 494 <div class="basis-1/3 text-center font-mono text-xl font-bold"> 495 <A href="/" class="hover:underline"> ··· 508 </a> 509 </div> 510 </div> 511 + <LoginStatus /> 512 <div class="mb-5 flex max-w-full flex-col items-center text-pretty lg:max-w-screen-lg"> 513 <form 514 class="flex flex-col items-center gap-y-1"
+159
src/components/login.tsx
···
··· 1 + import { createSignal, onMount, Show, type Component } from "solid-js"; 2 + import { 3 + configureOAuth, 4 + createAuthorizationUrl, 5 + finalizeAuthorization, 6 + getSession, 7 + OAuthUserAgent, 8 + resolveFromIdentity, 9 + type Session, 10 + } from "@atcute/oauth-browser-client"; 11 + import { At } from "@atcute/client/lexicons"; 12 + 13 + configureOAuth({ 14 + metadata: { 15 + client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 16 + redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 17 + }, 18 + }); 19 + 20 + const [loginState, setLoginState] = createSignal(false); 21 + const [notice, setNotice] = createSignal(""); 22 + const [handle, setHandle] = createSignal(""); 23 + let agent: OAuthUserAgent; 24 + 25 + const resolveDid = async (did: string) => { 26 + const res = await fetch( 27 + did.startsWith("did:web") ? 28 + `https://${did.split(":")[2]}/.well-known/did.json` 29 + : "https://plc.directory/" + did, 30 + ); 31 + 32 + return res 33 + .json() 34 + .then((doc) => { 35 + for (const alias of doc.alsoKnownAs) { 36 + if (alias.includes("at://")) { 37 + return alias.split("//")[1]; 38 + } 39 + } 40 + }) 41 + .catch(() => ""); 42 + }; 43 + 44 + const Login: Component = () => { 45 + const [loginInput, setLoginInput] = createSignal(""); 46 + 47 + const loginBsky = async (handle: string) => { 48 + try { 49 + setNotice(`Resolving your identity...`); 50 + const resolved = await resolveFromIdentity(handle); 51 + 52 + setNotice(`Contacting your data server...`); 53 + const authUrl = await createAuthorizationUrl({ 54 + scope: import.meta.env.VITE_OAUTH_SCOPE, 55 + ...resolved, 56 + }); 57 + 58 + setNotice(`Redirecting...`); 59 + await new Promise((resolve) => setTimeout(resolve, 250)); 60 + 61 + location.assign(authUrl); 62 + } catch { 63 + setNotice("Error during OAuth login"); 64 + } 65 + }; 66 + 67 + return ( 68 + <div class="mt-2 font-sans"> 69 + <form class="flex flex-col" onsubmit={(e) => e.preventDefault()}> 70 + <div class="w-full"> 71 + <label for="handle" class="ml-0.5 text-sm"> 72 + Handle 73 + </label> 74 + </div> 75 + <div class="flex gap-x-2"> 76 + <input 77 + type="text" 78 + id="handle" 79 + placeholder="user.bsky.social" 80 + class="dark:bg-dark-100 rounded-lg border border-gray-400 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-gray-300" 81 + onInput={(e) => setLoginInput(e.currentTarget.value)} 82 + /> 83 + <button 84 + onclick={() => loginBsky(loginInput())} 85 + class="dark:bg-dark-700 dark:hover:bg-dark-800 rounded-lg border border-gray-400 bg-white px-2.5 py-1.5 text-sm font-bold hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-gray-300" 86 + > 87 + Login 88 + </button> 89 + </div> 90 + </form> 91 + <Show when={notice()}> 92 + <div class="mt-2">{notice()}</div> 93 + </Show> 94 + </div> 95 + ); 96 + }; 97 + 98 + const LoginStatus: Component = () => { 99 + onMount(async () => { 100 + setNotice("Loading..."); 101 + 102 + const init = async (): Promise<Session | undefined> => { 103 + const params = new URLSearchParams(location.hash.slice(1)); 104 + 105 + if (params.has("state") && (params.has("code") || params.has("error"))) { 106 + history.replaceState(null, "", location.pathname + location.search); 107 + 108 + const session = await finalizeAuthorization(params); 109 + const did = session.info.sub; 110 + 111 + localStorage.setItem("lastSignedIn", did); 112 + return session; 113 + } else { 114 + const lastSignedIn = localStorage.getItem("lastSignedIn"); 115 + 116 + if (lastSignedIn) { 117 + try { 118 + return await getSession(lastSignedIn as At.DID); 119 + } catch (err) { 120 + localStorage.removeItem("lastSignedIn"); 121 + throw err; 122 + } 123 + } 124 + } 125 + }; 126 + 127 + const session = await init().catch(() => {}); 128 + 129 + if (session) { 130 + agent = new OAuthUserAgent(session); 131 + setHandle(await resolveDid(agent.sub)); 132 + setLoginState(true); 133 + } 134 + 135 + setNotice(""); 136 + }); 137 + 138 + const logoutBsky = async () => { 139 + setLoginState(false); 140 + await agent.signOut(); 141 + }; 142 + 143 + return ( 144 + <Show when={loginState() && handle()}> 145 + <div> 146 + Logged in as @{handle()} 147 + <a 148 + href="" 149 + class="ml-2 text-red-500 dark:text-red-400" 150 + onclick={() => logoutBsky()} 151 + > 152 + Logout 153 + </a> 154 + </div> 155 + </Show> 156 + ); 157 + }; 158 + 159 + export { Login, LoginStatus, loginState, agent };
src/index.css src/styles/index.css
+5 -3
src/index.tsx
··· 1 /* @refresh reload */ 2 import { render } from "solid-js/web"; 3 import "virtual:uno.css"; 4 - import "./tailwind-compat.css"; 5 - import "./index.css"; 6 import { Route, Router } from "@solidjs/router"; 7 import { 8 Layout, ··· 11 RecordView, 12 RepoView, 13 Home, 14 - } from "./App.tsx"; 15 16 render( 17 () => ( 18 <Router root={Layout}> 19 <Route path="/" component={Home} /> 20 <Route path="/:pds" component={PdsView} /> 21 <Route path="/:pds/:repo" component={RepoView} /> 22 <Route path="/:pds/:repo/:collection" component={CollectionView} />
··· 1 /* @refresh reload */ 2 import { render } from "solid-js/web"; 3 import "virtual:uno.css"; 4 + import "./styles/tailwind-compat.css"; 5 + import "./styles/index.css"; 6 import { Route, Router } from "@solidjs/router"; 7 import { 8 Layout, ··· 11 RecordView, 12 RepoView, 13 Home, 14 + } from "./main.tsx"; 15 + import { Login } from "./components/login.tsx"; 16 17 render( 18 () => ( 19 <Router root={Layout}> 20 <Route path="/" component={Home} /> 21 + <Route path="/login" component={Login} /> 22 <Route path="/:pds" component={PdsView} /> 23 <Route path="/:pds/:repo" component={RepoView} /> 24 <Route path="/:pds/:repo/:collection" component={CollectionView} />
src/lib/json.tsx src/components/json.tsx
src/lib/svg.tsx src/components/svg.tsx
src/lib/video-player.tsx src/components/video-player.tsx
src/tailwind-compat.css src/styles/tailwind-compat.css
+14
src/vite-env.d.ts
···
··· 1 + /// <reference types="vite/client" /> 2 + /// <reference types="@atcute/bluesky/lexicons" /> 3 + 4 + interface ImportMetaEnv { 5 + readonly VITE_DEV_SERVER_PORT?: string; 6 + readonly VITE_CLIENT_URI: string; 7 + readonly VITE_OAUTH_CLIENT_ID: string; 8 + readonly VITE_OAUTH_REDIRECT_URL: string; 9 + readonly VITE_OAUTH_SCOPE: string; 10 + } 11 + 12 + interface ImportMeta { 13 + readonly env: ImportMetaEnv; 14 + }
+33 -1
vite.config.ts
··· 2 import solidPlugin from "vite-plugin-solid"; 3 import wasm from "vite-plugin-wasm"; 4 import UnoCSS from "unocss/vite"; 5 6 const SERVER_HOST = "127.0.0.1"; 7 const SERVER_PORT = 13213; 8 9 export default defineConfig({ 10 - plugins: [UnoCSS(), wasm(), solidPlugin()], 11 server: { 12 host: SERVER_HOST, 13 port: SERVER_PORT,
··· 2 import solidPlugin from "vite-plugin-solid"; 3 import wasm from "vite-plugin-wasm"; 4 import UnoCSS from "unocss/vite"; 5 + import metadata from "./public/client-metadata.json"; 6 7 const SERVER_HOST = "127.0.0.1"; 8 const SERVER_PORT = 13213; 9 10 export default defineConfig({ 11 + plugins: [ 12 + UnoCSS(), 13 + wasm(), 14 + solidPlugin(), 15 + // Injects OAuth-related variables 16 + { 17 + name: "oauth", 18 + config(_conf, { command }) { 19 + if (command === "build") { 20 + process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id; 21 + process.env.VITE_OAUTH_REDIRECT_URL = metadata.redirect_uris[0]; 22 + } else { 23 + const redirectUri = ((): string => { 24 + const url = new URL(metadata.redirect_uris[0]); 25 + return `http://${SERVER_HOST}:${SERVER_PORT}${url.pathname}`; 26 + })(); 27 + 28 + const clientId = 29 + `http://localhost` + 30 + `?redirect_uri=${encodeURIComponent(redirectUri)}` + 31 + `&scope=${encodeURIComponent(metadata.scope)}`; 32 + 33 + process.env.VITE_DEV_SERVER_PORT = "" + SERVER_PORT; 34 + process.env.VITE_OAUTH_CLIENT_ID = clientId; 35 + process.env.VITE_OAUTH_REDIRECT_URL = redirectUri; 36 + } 37 + 38 + process.env.VITE_CLIENT_URI = metadata.client_uri; 39 + process.env.VITE_OAUTH_SCOPE = metadata.scope; 40 + }, 41 + }, 42 + ], 43 server: { 44 host: SERVER_HOST, 45 port: SERVER_PORT,