forked from pdsls.dev/pdsls
atproto explorer

add record deletion

+1
package.json
··· 19 19 }, 20 20 "dependencies": { 21 21 "@atcute/client": "^2.0.4", 22 + "@atcute/oauth-browser-client": "^1.0.5", 22 23 "@solidjs/router": "^0.15.1", 23 24 "hls.js": "^1.5.17", 24 25 "public-transport": "file:pkg/pt.tgz",
+18
pnpm-lock.yaml
··· 11 11 '@atcute/client': 12 12 specifier: ^2.0.4 13 13 version: 2.0.4 14 + '@atcute/oauth-browser-client': 15 + specifier: ^1.0.5 16 + version: 1.0.5 14 17 '@solidjs/router': 15 18 specifier: ^0.15.1 16 19 version: 0.15.1(solid-js@1.9.3) ··· 60 63 61 64 '@atcute/client@2.0.4': 62 65 resolution: {integrity: sha512-bKA6KEOmrdhU2CDRNp13M4WyKN0EdrVLKJffzPo62ANSTMacz5hRJhmvQYwuo7BZSGIoDql4sH+QR6Xbk3DERg==} 66 + 67 + '@atcute/oauth-browser-client@1.0.5': 68 + resolution: {integrity: sha512-UUs2WFMh22rXOapRM848WfWtvgaxV/ji0tEupFrrBYe2i+/UlwhXcphlqdwm43LBsFtMWtV1Xsy2zmnItf0Akg==} 63 69 64 70 '@babel/code-frame@7.26.2': 65 71 resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} ··· 914 920 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 915 921 hasBin: true 916 922 923 + nanoid@5.0.8: 924 + resolution: {integrity: sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==} 925 + engines: {node: ^18 || >=20} 926 + hasBin: true 927 + 917 928 node-fetch-native@1.6.4: 918 929 resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} 919 930 ··· 1203 1214 '@antfu/utils@0.7.10': {} 1204 1215 1205 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 1206 1222 1207 1223 '@babel/code-frame@7.26.2': 1208 1224 dependencies: ··· 2030 2046 ms@2.1.3: {} 2031 2047 2032 2048 nanoid@3.3.7: {} 2049 + 2050 + nanoid@5.0.8: {} 2033 2051 2034 2052 node-fetch-native@1.6.4: {} 2035 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"; 1 + import { 2 + createSignal, 3 + onMount, 4 + For, 5 + Show, 6 + type Component, 7 + onCleanup, 8 + createEffect, 9 + } from "solid-js"; 2 10 import { CredentialManager, XRPC } from "@atcute/client"; 3 11 import { 4 12 ComAtprotoRepoDescribeRepo, ··· 15 23 useLocation, 16 24 useParams, 17 25 } from "@solidjs/router"; 18 - import { JSONValue } from "./lib/json.jsx"; 26 + import { JSONValue } from "./components/json.jsx"; 19 27 import { 20 28 AiFillGithub, 21 29 Bluesky, ··· 23 31 BsClipboardCheck, 24 32 TbMoonStar, 25 33 TbSun, 26 - } from "./lib/svg.jsx"; 34 + } from "./components/svg.jsx"; 27 35 import { authenticate_post } from "public-transport"; 36 + import { agent, loginState, LoginStatus } from "./components/login.jsx"; 28 37 29 38 let rpc = new XRPC({ 30 39 handler: new CredentialManager({ service: "https://public.api.bsky.app" }), ··· 109 118 const RecordView: Component = () => { 110 119 const params = useParams(); 111 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 + }; 112 130 113 131 onMount(async () => { 132 + window.addEventListener("click", clickEvent); 133 + window.addEventListener("keydown", keyEvent); 114 134 setNotice("Loading..."); 115 135 setPDS(params.pds); 116 136 let pds = ··· 131 151 } 132 152 }); 133 153 154 + onCleanup(() => { 155 + window.removeEventListener("click", clickEvent); 156 + window.removeEventListener("keydown", keyEvent); 157 + }); 158 + 134 159 const getRecord = query( 135 160 (repo: string, collection: string, rkey: string) => 136 161 rpc.get("com.atproto.repo.getRecord", { ··· 139 164 "getRecord", 140 165 ); 141 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 + 142 184 return ( 143 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> 144 222 <div class="overflow-y-auto pl-4"> 145 223 <JSONValue data={record() as any} repo={record()!.uri.split("/")[2]} /> 146 224 </div> ··· 387 465 setNotice(""); 388 466 389 467 return ( 390 - <div class="m-5 flex flex-col items-center text-slate-900 dark:text-slate-100"> 468 + <div 469 + id="main" 470 + class="m-5 flex flex-col items-center text-slate-900 dark:text-slate-100" 471 + > 391 472 <div class="mb-2 flex w-[20rem] items-center"> 392 - <div class="basis-1/3"> 473 + <div class="flex basis-1/3 gap-x-2"> 393 474 <div 394 475 class="w-fit cursor-pointer" 395 476 onclick={() => { ··· 404 485 <TbMoonStar class="size-6" /> 405 486 : <TbSun class="size-6" />} 406 487 </div> 488 + <Show when={!loginState()}> 489 + <div> 490 + <A href="/login">Login</A> 491 + </div> 492 + </Show> 407 493 </div> 408 494 <div class="basis-1/3 text-center font-mono text-xl font-bold"> 409 495 <A href="/" class="hover:underline"> ··· 422 508 </a> 423 509 </div> 424 510 </div> 511 + <LoginStatus /> 425 512 <div class="mb-5 flex max-w-full flex-col items-center text-pretty lg:max-w-screen-lg"> 426 513 <form 427 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 1 /* @refresh reload */ 2 2 import { render } from "solid-js/web"; 3 3 import "virtual:uno.css"; 4 - import "./tailwind-compat.css"; 5 - import "./index.css"; 4 + import "./styles/tailwind-compat.css"; 5 + import "./styles/index.css"; 6 6 import { Route, Router } from "@solidjs/router"; 7 7 import { 8 8 Layout, ··· 11 11 RecordView, 12 12 RepoView, 13 13 Home, 14 - } from "./App.tsx"; 14 + } from "./main.tsx"; 15 + import { Login } from "./components/login.tsx"; 15 16 16 17 render( 17 18 () => ( 18 19 <Router root={Layout}> 19 20 <Route path="/" component={Home} /> 21 + <Route path="/login" component={Login} /> 20 22 <Route path="/:pds" component={PdsView} /> 21 23 <Route path="/:pds/:repo" component={RepoView} /> 22 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 2 import solidPlugin from "vite-plugin-solid"; 3 3 import wasm from "vite-plugin-wasm"; 4 4 import UnoCSS from "unocss/vite"; 5 + import metadata from "./public/client-metadata.json"; 5 6 6 7 const SERVER_HOST = "127.0.0.1"; 7 8 const SERVER_PORT = 13213; 8 9 9 10 export default defineConfig({ 10 - plugins: [UnoCSS(), wasm(), solidPlugin()], 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 + ], 11 43 server: { 12 44 host: SERVER_HOST, 13 45 port: SERVER_PORT,