atproto explorer pdsls.dev
atproto tool

add record deletion

+1
package.json
···
},
"dependencies": {
"@atcute/client": "^2.0.4",
+
"@atcute/oauth-browser-client": "^1.0.5",
"@solidjs/router": "^0.15.1",
"hls.js": "^1.5.17",
"public-transport": "file:pkg/pt.tgz",
+18
pnpm-lock.yaml
···
'@atcute/client':
specifier: ^2.0.4
version: 2.0.4
+
'@atcute/oauth-browser-client':
+
specifier: ^1.0.5
+
version: 1.0.5
'@solidjs/router':
specifier: ^0.15.1
version: 0.15.1(solid-js@1.9.3)
···
'@atcute/client@2.0.4':
resolution: {integrity: sha512-bKA6KEOmrdhU2CDRNp13M4WyKN0EdrVLKJffzPo62ANSTMacz5hRJhmvQYwuo7BZSGIoDql4sH+QR6Xbk3DERg==}
+
+
'@atcute/oauth-browser-client@1.0.5':
+
resolution: {integrity: sha512-UUs2WFMh22rXOapRM848WfWtvgaxV/ji0tEupFrrBYe2i+/UlwhXcphlqdwm43LBsFtMWtV1Xsy2zmnItf0Akg==}
'@babel/code-frame@7.26.2':
resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==}
···
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+
nanoid@5.0.8:
+
resolution: {integrity: sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==}
+
engines: {node: ^18 || >=20}
+
hasBin: true
+
node-fetch-native@1.6.4:
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
···
'@antfu/utils@0.7.10': {}
'@atcute/client@2.0.4': {}
+
+
'@atcute/oauth-browser-client@1.0.5':
+
dependencies:
+
'@atcute/client': 2.0.4
+
nanoid: 5.0.8
'@babel/code-frame@7.26.2':
dependencies:
···
ms@2.1.3: {}
nanoid@3.3.7: {}
+
+
nanoid@5.0.8: {}
node-fetch-native@1.6.4: {}
+12
public/client-metadata.json
···
+
{
+
"client_id": "https://pdsls.dev/client-metadata.json",
+
"client_name": "pdsls",
+
"client_uri": "https://pdsls.dev",
+
"redirect_uris": ["https://pdsls.dev/"],
+
"scope": "atproto transition:generic",
+
"grant_types": ["authorization_code", "refresh_token"],
+
"response_types": ["code"],
+
"token_endpoint_auth_method": "none",
+
"application_type": "web",
+
"dpop_bound_access_tokens": true
+
}
+92 -5
src/App.tsx src/main.tsx
···
-
import { createSignal, onMount, For, Show, type Component } from "solid-js";
+
import {
+
createSignal,
+
onMount,
+
For,
+
Show,
+
type Component,
+
onCleanup,
+
createEffect,
+
} from "solid-js";
import { CredentialManager, XRPC } from "@atcute/client";
import {
ComAtprotoRepoDescribeRepo,
···
useLocation,
useParams,
} from "@solidjs/router";
-
import { JSONValue } from "./lib/json.jsx";
+
import { JSONValue } from "./components/json.jsx";
import {
AiFillGithub,
Bluesky,
···
BsClipboardCheck,
TbMoonStar,
TbSun,
-
} from "./lib/svg.jsx";
+
} from "./components/svg.jsx";
import { authenticate_post } from "public-transport";
+
import { agent, loginState, LoginStatus } from "./components/login.jsx";
let rpc = new XRPC({
handler: new CredentialManager({ service: "https://public.api.bsky.app" }),
···
const RecordView: Component = () => {
const params = useParams();
const [record, setRecord] = createSignal<ComAtprotoRepoGetRecord.Output>();
+
const [modal, setModal] = createSignal<HTMLDialogElement>();
+
const [open, setOpen] = createSignal(false);
+
+
let clickEvent = (event: MouseEvent) => {
+
if (modal() && event.target == modal()) setOpen(false);
+
};
+
let keyEvent = (event: KeyboardEvent) => {
+
if (modal() && event.key == "Escape") setOpen(false);
+
};
onMount(async () => {
+
window.addEventListener("click", clickEvent);
+
window.addEventListener("keydown", keyEvent);
setNotice("Loading...");
setPDS(params.pds);
let pds =
···
}
});
+
onCleanup(() => {
+
window.removeEventListener("click", clickEvent);
+
window.removeEventListener("keydown", keyEvent);
+
});
+
const getRecord = query(
(repo: string, collection: string, rkey: string) =>
rpc.get("com.atproto.repo.getRecord", {
···
"getRecord",
);
+
const deleteRecord = action(async () => {
+
rpc = new XRPC({ handler: agent });
+
rpc.call("com.atproto.repo.deleteRecord", {
+
data: {
+
repo: params.repo,
+
collection: params.collection,
+
rkey: params.rkey,
+
},
+
});
+
throw redirect(`/at/${params.repo}/${params.collection}`);
+
});
+
+
createEffect(() => {
+
if (open()) document.body.style.overflow = "hidden";
+
else document.body.style.overflow = "auto";
+
});
+
return (
<Show when={record()}>
+
<Show when={loginState() && agent.sub === params.repo}>
+
<div class="flex w-full justify-center">
+
<Show when={open()}>
+
<dialog
+
ref={setModal}
+
class="fixed left-0 top-0 z-[2] flex h-screen w-screen items-center justify-center bg-transparent font-sans"
+
>
+
<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">
+
<h3 class="text-lg font-bold">Delete this record?</h3>
+
<form action={deleteRecord} method="post">
+
<div class="mt-2 inline-flex gap-2">
+
<button
+
onclick={() => setOpen(false)}
+
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"
+
>
+
Cancel
+
</button>
+
<button
+
type="submit"
+
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"
+
>
+
Delete
+
</button>
+
</div>
+
</form>
+
</div>
+
</dialog>
+
</Show>
+
<button
+
onclick={() => setOpen(true)}
+
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"
+
>
+
Delete
+
</button>
+
</div>
+
</Show>
<div class="overflow-y-auto pl-4">
<JSONValue data={record() as any} repo={record()!.uri.split("/")[2]} />
</div>
···
setNotice("");
return (
-
<div class="m-5 flex flex-col items-center text-slate-900 dark:text-slate-100">
+
<div
+
id="main"
+
class="m-5 flex flex-col items-center text-slate-900 dark:text-slate-100"
+
>
<div class="mb-2 flex w-[20rem] items-center">
-
<div class="basis-1/3">
+
<div class="flex basis-1/3 gap-x-2">
<div
class="w-fit cursor-pointer"
onclick={() => {
···
<TbMoonStar class="size-6" />
: <TbSun class="size-6" />}
</div>
+
<Show when={!loginState()}>
+
<div>
+
<A href="/login">Login</A>
+
</div>
+
</Show>
</div>
<div class="basis-1/3 text-center font-mono text-xl font-bold">
<A href="/" class="hover:underline">
···
</a>
</div>
</div>
+
<LoginStatus />
<div class="mb-5 flex max-w-full flex-col items-center text-pretty lg:max-w-screen-lg">
<form
class="flex flex-col items-center gap-y-1"
+159
src/components/login.tsx
···
+
import { createSignal, onMount, Show, type Component } from "solid-js";
+
import {
+
configureOAuth,
+
createAuthorizationUrl,
+
finalizeAuthorization,
+
getSession,
+
OAuthUserAgent,
+
resolveFromIdentity,
+
type Session,
+
} from "@atcute/oauth-browser-client";
+
import { At } from "@atcute/client/lexicons";
+
+
configureOAuth({
+
metadata: {
+
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
+
redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL,
+
},
+
});
+
+
const [loginState, setLoginState] = createSignal(false);
+
const [notice, setNotice] = createSignal("");
+
const [handle, setHandle] = createSignal("");
+
let agent: OAuthUserAgent;
+
+
const resolveDid = async (did: string) => {
+
const res = await fetch(
+
did.startsWith("did:web") ?
+
`https://${did.split(":")[2]}/.well-known/did.json`
+
: "https://plc.directory/" + did,
+
);
+
+
return res
+
.json()
+
.then((doc) => {
+
for (const alias of doc.alsoKnownAs) {
+
if (alias.includes("at://")) {
+
return alias.split("//")[1];
+
}
+
}
+
})
+
.catch(() => "");
+
};
+
+
const Login: Component = () => {
+
const [loginInput, setLoginInput] = createSignal("");
+
+
const loginBsky = async (handle: string) => {
+
try {
+
setNotice(`Resolving your identity...`);
+
const resolved = await resolveFromIdentity(handle);
+
+
setNotice(`Contacting your data server...`);
+
const authUrl = await createAuthorizationUrl({
+
scope: import.meta.env.VITE_OAUTH_SCOPE,
+
...resolved,
+
});
+
+
setNotice(`Redirecting...`);
+
await new Promise((resolve) => setTimeout(resolve, 250));
+
+
location.assign(authUrl);
+
} catch {
+
setNotice("Error during OAuth login");
+
}
+
};
+
+
return (
+
<div class="mt-2 font-sans">
+
<form class="flex flex-col" onsubmit={(e) => e.preventDefault()}>
+
<div class="w-full">
+
<label for="handle" class="ml-0.5 text-sm">
+
Handle
+
</label>
+
</div>
+
<div class="flex gap-x-2">
+
<input
+
type="text"
+
id="handle"
+
placeholder="user.bsky.social"
+
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"
+
onInput={(e) => setLoginInput(e.currentTarget.value)}
+
/>
+
<button
+
onclick={() => loginBsky(loginInput())}
+
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"
+
>
+
Login
+
</button>
+
</div>
+
</form>
+
<Show when={notice()}>
+
<div class="mt-2">{notice()}</div>
+
</Show>
+
</div>
+
);
+
};
+
+
const LoginStatus: Component = () => {
+
onMount(async () => {
+
setNotice("Loading...");
+
+
const init = async (): Promise<Session | undefined> => {
+
const params = new URLSearchParams(location.hash.slice(1));
+
+
if (params.has("state") && (params.has("code") || params.has("error"))) {
+
history.replaceState(null, "", location.pathname + location.search);
+
+
const session = await finalizeAuthorization(params);
+
const did = session.info.sub;
+
+
localStorage.setItem("lastSignedIn", did);
+
return session;
+
} else {
+
const lastSignedIn = localStorage.getItem("lastSignedIn");
+
+
if (lastSignedIn) {
+
try {
+
return await getSession(lastSignedIn as At.DID);
+
} catch (err) {
+
localStorage.removeItem("lastSignedIn");
+
throw err;
+
}
+
}
+
}
+
};
+
+
const session = await init().catch(() => {});
+
+
if (session) {
+
agent = new OAuthUserAgent(session);
+
setHandle(await resolveDid(agent.sub));
+
setLoginState(true);
+
}
+
+
setNotice("");
+
});
+
+
const logoutBsky = async () => {
+
setLoginState(false);
+
await agent.signOut();
+
};
+
+
return (
+
<Show when={loginState() && handle()}>
+
<div>
+
Logged in as @{handle()}
+
<a
+
href=""
+
class="ml-2 text-red-500 dark:text-red-400"
+
onclick={() => logoutBsky()}
+
>
+
Logout
+
</a>
+
</div>
+
</Show>
+
);
+
};
+
+
export { Login, LoginStatus, loginState, agent };
src/index.css src/styles/index.css
+5 -3
src/index.tsx
···
/* @refresh reload */
import { render } from "solid-js/web";
import "virtual:uno.css";
-
import "./tailwind-compat.css";
-
import "./index.css";
+
import "./styles/tailwind-compat.css";
+
import "./styles/index.css";
import { Route, Router } from "@solidjs/router";
import {
Layout,
···
RecordView,
RepoView,
Home,
-
} from "./App.tsx";
+
} from "./main.tsx";
+
import { Login } from "./components/login.tsx";
render(
() => (
<Router root={Layout}>
<Route path="/" component={Home} />
+
<Route path="/login" component={Login} />
<Route path="/:pds" component={PdsView} />
<Route path="/:pds/:repo" component={RepoView} />
<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
···
+
/// <reference types="vite/client" />
+
/// <reference types="@atcute/bluesky/lexicons" />
+
+
interface ImportMetaEnv {
+
readonly VITE_DEV_SERVER_PORT?: string;
+
readonly VITE_CLIENT_URI: string;
+
readonly VITE_OAUTH_CLIENT_ID: string;
+
readonly VITE_OAUTH_REDIRECT_URL: string;
+
readonly VITE_OAUTH_SCOPE: string;
+
}
+
+
interface ImportMeta {
+
readonly env: ImportMetaEnv;
+
}
+33 -1
vite.config.ts
···
import solidPlugin from "vite-plugin-solid";
import wasm from "vite-plugin-wasm";
import UnoCSS from "unocss/vite";
+
import metadata from "./public/client-metadata.json";
const SERVER_HOST = "127.0.0.1";
const SERVER_PORT = 13213;
export default defineConfig({
-
plugins: [UnoCSS(), wasm(), solidPlugin()],
+
plugins: [
+
UnoCSS(),
+
wasm(),
+
solidPlugin(),
+
// Injects OAuth-related variables
+
{
+
name: "oauth",
+
config(_conf, { command }) {
+
if (command === "build") {
+
process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id;
+
process.env.VITE_OAUTH_REDIRECT_URL = metadata.redirect_uris[0];
+
} else {
+
const redirectUri = ((): string => {
+
const url = new URL(metadata.redirect_uris[0]);
+
return `http://${SERVER_HOST}:${SERVER_PORT}${url.pathname}`;
+
})();
+
+
const clientId =
+
`http://localhost` +
+
`?redirect_uri=${encodeURIComponent(redirectUri)}` +
+
`&scope=${encodeURIComponent(metadata.scope)}`;
+
+
process.env.VITE_DEV_SERVER_PORT = "" + SERVER_PORT;
+
process.env.VITE_OAUTH_CLIENT_ID = clientId;
+
process.env.VITE_OAUTH_REDIRECT_URL = redirectUri;
+
}
+
+
process.env.VITE_CLIENT_URI = metadata.client_uri;
+
process.env.VITE_OAUTH_SCOPE = metadata.scope;
+
},
+
},
+
],
server: {
host: SERVER_HOST,
port: SERVER_PORT,