atproto explorer pdsls.dev
atproto tool

Compare changes

Choose any two refs to compare.

public/headers/sonic.jpg

This is a binary file and will not be displayed.

-66
src/utils/types/at-uri.ts
···
-
type Did<TMethod extends string = string> = `did:${TMethod}:${string}`;
-
-
type Nsid = `${string}.${string}.${string}`;
-
-
type RecordKey = string;
-
-
const DID_RE = /^did:([a-z]+):([a-zA-Z0-9._:%\-]*[a-zA-Z0-9._\-])$/;
-
-
const NSID_RE =
-
/^[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z0-9]{0,62})?)$/;
-
-
const RECORD_KEY_RE = /^(?!\.{1,2}$)[a-zA-Z0-9_~.:-]{1,512}$/;
-
-
export const ATURI_RE =
-
/^at:\/\/([a-zA-Z0-9._:%-]+)(?:\/([a-zA-Z0-9-.]+)(?:\/([a-zA-Z0-9._~:@!$&%')(*+,;=-]+))?)?(?:#(\/[a-zA-Z0-9._~:@!$&%')(*+,;=\-[\]/\\]*))?$/;
-
-
const isDid = (input: unknown): input is Did => {
-
return (
-
typeof input === "string" && input.length >= 7 && input.length <= 2048 && DID_RE.test(input)
-
);
-
};
-
-
const isNsid = (input: unknown): input is Nsid => {
-
return (
-
typeof input === "string" && input.length >= 5 && input.length <= 317 && NSID_RE.test(input)
-
);
-
};
-
-
const isRecordKey = (input: unknown): input is RecordKey => {
-
return (
-
typeof input === "string" &&
-
input.length >= 1 &&
-
input.length <= 512 &&
-
RECORD_KEY_RE.test(input)
-
);
-
};
-
-
export interface AddressedAtUri {
-
repo: Did;
-
collection: Nsid;
-
rkey: string;
-
fragment: string | undefined;
-
}
-
-
export const parseAddressedAtUri = (str: string): AddressedAtUri => {
-
const match = ATURI_RE.exec(str);
-
assert(match !== null, `invalid addressed-at-uri: ${str}`);
-
-
const [, r, c, k, f] = match;
-
assert(isDid(r), `invalid repo in addressed-at-uri: ${r}`);
-
assert(isNsid(c), `invalid collection in addressed-at-uri: ${c}`);
-
assert(isRecordKey(k), `invalid rkey in addressed-at-uri: ${k}`);
-
-
return {
-
repo: r,
-
collection: c,
-
rkey: k,
-
fragment: f,
-
};
-
};
-
-
function assert(condition: boolean, message: string): asserts condition {
-
if (!condition) {
-
throw new Error(message);
-
}
-
}
+22 -1
vite.config.ts
···
import tailwindcss from "@tailwindcss/vite";
+
import { execSync } from "child_process";
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
import metadata from "./public/oauth-client-metadata.json";
···
const SERVER_HOST = "127.0.0.1";
const SERVER_PORT = 13213;
+
const getVersion = (): string => {
+
try {
+
const describe = execSync("git describe --tags --long --dirty --always").toString().trim();
+
+
const match = describe.match(/^v?(.+?)-(\d+)-g([a-f0-9]+)(-dirty)?$/);
+
+
if (match) {
+
const [, version, commits, hash, dirty] = match;
+
if (commits === "0") {
+
return `v${version}${dirty ? "-dirty" : ""}`;
+
}
+
return `v${version}.dev${commits}+g${hash}${dirty ? "-dirty" : ""}`;
+
}
+
+
return `v0.0.0.dev+g${describe}`;
+
} catch {
+
return "v0.0.0-unknown";
+
}
+
};
+
export default defineConfig({
plugins: [
tailwindcss(),
solidPlugin(),
-
// Injects OAuth-related variables
{
name: "oauth",
config(_conf, { command }) {
···
process.env.VITE_CLIENT_URI = metadata.client_uri;
process.env.VITE_OAUTH_SCOPE = metadata.scope;
+
process.env.VITE_APP_VERSION = getVersion();
},
},
],
-10
src/utils/app-urls.ts
···
export enum App {
Bluesky,
Tangled,
-
Whitewind,
Frontpage,
Pinksea,
Linkat,
···
export const appName = {
[App.Bluesky]: "Bluesky",
[App.Tangled]: "Tangled",
-
[App.Whitewind]: "Whitewind",
[App.Frontpage]: "Frontpage",
[App.Pinksea]: "Pinksea",
[App.Linkat]: "Linkat",
···
"main.bsky.dev": App.Bluesky,
"social.daniela.lol": App.Bluesky,
"tangled.org": App.Tangled,
-
"whtwnd.com": App.Whitewind,
"frontpage.fyi": App.Frontpage,
"pinksea.art": App.Pinksea,
"linkat.blue": App.Linkat,
···
return `at://${user}`;
},
-
[App.Whitewind]: (path) => {
-
if (path.length === 2) {
-
return `at://${path[0]}/com.whtwnd.blog.entry/${path[1]}`;
-
}
-
-
return `at://${path[0]}/com.whtwnd.blog.entry`;
-
},
[App.Frontpage]: (path) => {
if (path.length === 3) {
return `at://${path[1]}/fyi.unravel.frontpage.post/${path[2]}`;
+7 -1
src/components/video-player.tsx
···
});
return (
-
<video ref={video} class="max-h-80 max-w-[20rem]" controls playsinline onLoadedData={props.onLoad}>
+
<video
+
ref={video}
+
class="max-h-80 max-w-[20rem]"
+
controls
+
playsinline
+
onLoadedData={props.onLoad}
+
>
<source type="video/mp4" />
</video>
);
+15 -15
src/utils/hooks/debounced.ts
···
-
import { type Accessor, createEffect, createSignal, onCleanup } from 'solid-js';
+
import { type Accessor, createEffect, createSignal, onCleanup } from "solid-js";
export const createDebouncedValue = <T>(
-
accessor: Accessor<T>,
-
delay: number,
-
equals?: false | ((prev: T, next: T) => boolean),
+
accessor: Accessor<T>,
+
delay: number,
+
equals?: false | ((prev: T, next: T) => boolean),
): Accessor<T> => {
-
const initial = accessor();
-
const [state, setState] = createSignal(initial, { equals });
+
const initial = accessor();
+
const [state, setState] = createSignal(initial, { equals });
-
createEffect((prev: T) => {
-
const next = accessor();
+
createEffect((prev: T) => {
+
const next = accessor();
-
if (prev !== next) {
-
const timeout = setTimeout(() => setState(() => next), delay);
-
onCleanup(() => clearTimeout(timeout));
-
}
+
if (prev !== next) {
+
const timeout = setTimeout(() => setState(() => next), delay);
+
onCleanup(() => clearTimeout(timeout));
+
}
-
return next;
-
}, initial);
+
return next;
+
}, initial);
-
return state;
+
return state;
};
+30
src/utils/key.ts
···
+
import { parseDidKey, parsePublicMultikey } from "@atcute/crypto";
+
import { fromBase58Btc } from "@atcute/multibase";
+
+
export const detectKeyType = (key: string): string => {
+
try {
+
return parsePublicMultikey(key).type;
+
} catch (e) {
+
try {
+
const bytes = fromBase58Btc(key.startsWith("z") ? key.slice(1) : key);
+
if (bytes.length >= 2) {
+
const type = (bytes[0] << 8) | bytes[1];
+
if (type === 0xed01) {
+
return "ed25519";
+
}
+
}
+
} catch {}
+
return "unknown";
+
}
+
};
+
+
export const detectDidKeyType = (key: string): string => {
+
try {
+
return parseDidKey(key).type;
+
} catch (e) {
+
if (key.startsWith("did:key:")) {
+
return detectKeyType(key.slice(8));
+
}
+
return "unknown";
+
}
+
};
+1 -1
public/oauth-client-metadata.json
···
"client_uri": "https://pdsls.dev",
"logo_uri": "https://pdsls.dev/favicon.ico",
"redirect_uris": ["https://pdsls.dev/"],
-
"scope": "atproto transition:generic",
+
"scope": "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
+13
src/auth/oauth-config.ts
···
+
import { configureOAuth, defaultIdentityResolver } from "@atcute/oauth-browser-client";
+
import { didDocumentResolver, handleResolver } from "../utils/api";
+
+
configureOAuth({
+
metadata: {
+
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
+
redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL,
+
},
+
identityResolver: defaultIdentityResolver({
+
handleResolver: handleResolver,
+
didDocumentResolver: didDocumentResolver,
+
}),
+
});
+77
src/auth/scope-flow.ts
···
+
import { isDid, isHandle } from "@atcute/lexicons/syntax";
+
import { createAuthorizationUrl } from "@atcute/oauth-browser-client";
+
import { createSignal } from "solid-js";
+
+
interface UseOAuthScopeFlowOptions {
+
onError?: (error: unknown) => void;
+
onRedirecting?: () => void;
+
beforeRedirect?: (account: string) => Promise<void>;
+
}
+
+
export const useOAuthScopeFlow = (options: UseOAuthScopeFlowOptions = {}) => {
+
const [showScopeSelector, setShowScopeSelector] = createSignal(false);
+
const [pendingAccount, setPendingAccount] = createSignal("");
+
const [shouldForceRedirect, setShouldForceRedirect] = createSignal(false);
+
+
const initiate = (account: string) => {
+
if (!account) return;
+
setPendingAccount(account);
+
setShouldForceRedirect(false);
+
setShowScopeSelector(true);
+
};
+
+
const initiateWithRedirect = (account: string) => {
+
if (!account) return;
+
setPendingAccount(account);
+
setShouldForceRedirect(true);
+
setShowScopeSelector(true);
+
};
+
+
const complete = async (scopeString: string, scopeIds: string) => {
+
try {
+
const account = pendingAccount();
+
+
if (options.beforeRedirect && !shouldForceRedirect()) {
+
try {
+
await options.beforeRedirect(account);
+
setShowScopeSelector(false);
+
return;
+
} catch {}
+
}
+
+
localStorage.setItem("pendingScopes", scopeIds);
+
+
options.onRedirecting?.();
+
+
const authUrl = await createAuthorizationUrl({
+
scope: scopeString,
+
target:
+
isHandle(account) || isDid(account) ?
+
{ type: "account", identifier: account }
+
: { type: "pds", serviceUrl: account },
+
});
+
+
await new Promise((resolve) => setTimeout(resolve, 250));
+
location.assign(authUrl);
+
} catch (e) {
+
console.error(e);
+
options.onError?.(e);
+
setShowScopeSelector(false);
+
}
+
};
+
+
const cancel = () => {
+
setShowScopeSelector(false);
+
setPendingAccount("");
+
setShouldForceRedirect(false);
+
};
+
+
return {
+
showScopeSelector,
+
pendingAccount,
+
initiate,
+
initiateWithRedirect,
+
complete,
+
cancel,
+
};
+
};
+53
src/auth/scope-utils.ts
···
+
import { agent, sessions } from "./state";
+
+
export const GRANULAR_SCOPES = [
+
{
+
id: "create",
+
scope: "repo:*?action=create",
+
label: "Create records",
+
},
+
{
+
id: "update",
+
scope: "repo:*?action=update",
+
label: "Update records",
+
},
+
{
+
id: "delete",
+
scope: "repo:*?action=delete",
+
label: "Delete records",
+
},
+
{
+
id: "blob",
+
scope: "blob:*/*",
+
label: "Upload blobs",
+
},
+
];
+
+
export const BASE_SCOPES = ["atproto"];
+
+
export const buildScopeString = (selected: Set<string>): string => {
+
const granular = GRANULAR_SCOPES.filter((s) => selected.has(s.id)).map((s) => s.scope);
+
return [...BASE_SCOPES, ...granular].join(" ");
+
};
+
+
export const scopeIdsToString = (scopeIds: Set<string>): string => {
+
return ["atproto", ...Array.from(scopeIds)].join(",");
+
};
+
+
export const parseScopeString = (scopeIdsString: string): Set<string> => {
+
if (!scopeIdsString) return new Set();
+
const ids = scopeIdsString.split(",").filter(Boolean);
+
return new Set(ids.filter((id) => id !== "atproto"));
+
};
+
+
export const hasScope = (grantedScopes: string | undefined, scopeId: string): boolean => {
+
if (!grantedScopes) return false;
+
return grantedScopes.split(",").includes(scopeId);
+
};
+
+
export const hasUserScope = (scopeId: string): boolean => {
+
if (!agent()) return false;
+
const grantedScopes = sessions[agent()!.sub]?.grantedScopes;
+
if (!grantedScopes) return true;
+
return hasScope(grantedScopes, scopeId);
+
};
+14
src/auth/state.ts
···
+
import { OAuthUserAgent } from "@atcute/oauth-browser-client";
+
import { createSignal } from "solid-js";
+
import { createStore } from "solid-js/store";
+
+
export type Account = {
+
signedIn: boolean;
+
handle?: string;
+
grantedScopes?: string;
+
};
+
+
export type Sessions = Record<string, Account>;
+
+
export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>();
+
export const [sessions, setSessions] = createStore<Sessions>();
-170
src/components/account.tsx
···
-
import { Client, CredentialManager } from "@atcute/client";
-
import { Did } from "@atcute/lexicons";
-
import {
-
createAuthorizationUrl,
-
deleteStoredSession,
-
getSession,
-
OAuthUserAgent,
-
} from "@atcute/oauth-browser-client";
-
import { A } from "@solidjs/router";
-
import { createSignal, For, onMount, Show } from "solid-js";
-
import { createStore, produce } from "solid-js/store";
-
import { resolveDidDoc } from "../utils/api.js";
-
import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "./dropdown.jsx";
-
import { agent, Login, retrieveSession, Sessions, setAgent } from "./login.jsx";
-
import { Modal } from "./modal.jsx";
-
-
export const [sessions, setSessions] = createStore<Sessions>();
-
-
const AccountDropdown = (props: { did: Did }) => {
-
const removeSession = async (did: Did) => {
-
const currentSession = agent()?.sub;
-
try {
-
const session = await getSession(did, { allowStale: true });
-
const agent = new OAuthUserAgent(session);
-
await agent.signOut();
-
} catch {
-
deleteStoredSession(did);
-
}
-
setSessions(
-
produce((accs) => {
-
delete accs[did];
-
}),
-
);
-
localStorage.setItem("sessions", JSON.stringify(sessions));
-
if (currentSession === did) setAgent(undefined);
-
};
-
-
return (
-
<MenuProvider>
-
<DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-md p-2">
-
<NavMenu href={`/at://${props.did}`} label="Go to repo" icon="lucide--user-round" />
-
<ActionMenu
-
icon="lucide--x"
-
label="Remove account"
-
onClick={() => removeSession(props.did)}
-
/>
-
</DropdownMenu>
-
</MenuProvider>
-
);
-
};
-
-
export const AccountManager = () => {
-
const [openManager, setOpenManager] = createSignal(false);
-
const [avatars, setAvatars] = createStore<Record<Did, string>>();
-
-
onMount(async () => {
-
try {
-
await retrieveSession();
-
} catch {}
-
-
const localSessions = localStorage.getItem("sessions");
-
if (localSessions) {
-
const storedSessions: Sessions = JSON.parse(localSessions);
-
const sessionDids = Object.keys(storedSessions) as Did[];
-
sessionDids.forEach(async (did) => {
-
const doc = await resolveDidDoc(did);
-
const alias = doc.alsoKnownAs?.find((alias) => alias.startsWith("at://"));
-
if (alias) {
-
setSessions(did, {
-
signedIn: storedSessions[did].signedIn,
-
handle: alias.replace("at://", ""),
-
});
-
}
-
});
-
sessionDids.forEach(async (did) => {
-
const avatar = await getAvatar(did);
-
if (avatar) setAvatars(did, avatar);
-
});
-
}
-
});
-
-
const resumeSession = async (did: Did) => {
-
try {
-
localStorage.setItem("lastSignedIn", did);
-
await retrieveSession();
-
} catch {
-
const authUrl = await createAuthorizationUrl({
-
scope: import.meta.env.VITE_OAUTH_SCOPE,
-
target: { type: "account", identifier: did },
-
});
-
-
await new Promise((resolve) => setTimeout(resolve, 250));
-
-
location.assign(authUrl);
-
}
-
};
-
-
const getAvatar = async (did: Did) => {
-
const rpc = new Client({
-
handler: new CredentialManager({ service: "https://public.api.bsky.app" }),
-
});
-
const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: did } });
-
if (res.ok) {
-
return res.data.avatar;
-
}
-
return undefined;
-
};
-
-
return (
-
<>
-
<Modal open={openManager()} onClose={() => setOpenManager(false)}>
-
<div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-18 left-[50%] w-88 -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0">
-
<div class="mb-2 px-1 font-semibold">
-
<span>Manage accounts</span>
-
</div>
-
<div class="mb-3 max-h-80 overflow-y-auto md:max-h-100">
-
<For each={Object.keys(sessions)}>
-
{(did) => (
-
<div class="flex w-full items-center justify-between">
-
<A
-
href={`/at://${did}`}
-
onClick={() => setOpenManager(false)}
-
class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
-
>
-
<Show
-
when={avatars[did as Did]}
-
fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>}
-
>
-
<img
-
src={avatars[did as Did].replace("img/avatar/", "img/avatar_thumbnail/")}
-
class="size-6 rounded-full"
-
/>
-
</Show>
-
</A>
-
<button
-
class="flex grow items-center justify-between gap-1 truncate rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
-
onclick={() => resumeSession(did as Did)}
-
>
-
<span class="truncate">
-
{sessions[did]?.handle ? sessions[did].handle : did}
-
</span>
-
<Show when={did === agent()?.sub && sessions[did].signedIn}>
-
<span class="iconify lucide--check shrink-0 text-green-500 dark:text-green-400"></span>
-
</Show>
-
<Show when={!sessions[did].signedIn}>
-
<span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span>
-
</Show>
-
</button>
-
<AccountDropdown did={did as Did} />
-
</div>
-
)}
-
</For>
-
</div>
-
<Login />
-
</div>
-
</Modal>
-
<button
-
onclick={() => setOpenManager(true)}
-
class={`flex items-center rounded-lg ${agent() && avatars[agent()!.sub] ? "p-1.25" : "p-1.5"} hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600`}
-
>
-
{agent() && avatars[agent()!.sub] ?
-
<img
-
src={avatars[agent()!.sub].replace("img/avatar/", "img/avatar_thumbnail/")}
-
class="size-5 rounded-full"
-
/>
-
: <span class="iconify lucide--circle-user-round text-lg"></span>}
-
</button>
-
</>
-
);
-
};
-143
src/components/login.tsx
···
-
import { Client } from "@atcute/client";
-
import { Did } from "@atcute/lexicons";
-
import { isDid, isHandle } from "@atcute/lexicons/syntax";
-
import {
-
configureOAuth,
-
createAuthorizationUrl,
-
defaultIdentityResolver,
-
finalizeAuthorization,
-
getSession,
-
OAuthUserAgent,
-
type Session,
-
} from "@atcute/oauth-browser-client";
-
import { createSignal, Show } from "solid-js";
-
import { didDocumentResolver, handleResolver } from "../utils/api";
-
-
configureOAuth({
-
metadata: {
-
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
-
redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL,
-
},
-
identityResolver: defaultIdentityResolver({
-
handleResolver: handleResolver,
-
didDocumentResolver: didDocumentResolver,
-
}),
-
});
-
-
export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>();
-
-
type Account = {
-
signedIn: boolean;
-
handle?: string;
-
};
-
-
export type Sessions = Record<string, Account>;
-
-
const Login = () => {
-
const [notice, setNotice] = createSignal("");
-
const [loginInput, setLoginInput] = createSignal("");
-
-
const login = async (handle: string) => {
-
try {
-
setNotice("");
-
if (!handle) return;
-
setNotice(`Contacting your data server...`);
-
const authUrl = await createAuthorizationUrl({
-
scope: import.meta.env.VITE_OAUTH_SCOPE,
-
target:
-
isHandle(handle) || isDid(handle) ?
-
{ type: "account", identifier: handle }
-
: { type: "pds", serviceUrl: handle },
-
});
-
-
setNotice(`Redirecting...`);
-
await new Promise((resolve) => setTimeout(resolve, 250));
-
-
location.assign(authUrl);
-
} catch (e) {
-
console.error(e);
-
setNotice(`${e}`);
-
}
-
};
-
-
return (
-
<form class="flex flex-col gap-y-2 px-1" onsubmit={(e) => e.preventDefault()}>
-
<label for="username" class="hidden">
-
Add account
-
</label>
-
<div class="dark:bg-dark-100 dark:inset-shadow-dark-200 flex grow items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 inset-shadow-xs focus-within:outline-[1px] focus-within:outline-neutral-600 dark:border-neutral-600 dark:focus-within:outline-neutral-400">
-
<label
-
for="username"
-
class="iconify lucide--user-round-plus shrink-0 text-neutral-500 dark:text-neutral-400"
-
></label>
-
<input
-
type="text"
-
spellcheck={false}
-
placeholder="user.bsky.social"
-
id="username"
-
name="username"
-
autocomplete="username"
-
aria-label="Your AT Protocol handle"
-
class="grow py-1 select-none placeholder:text-sm focus:outline-none"
-
onInput={(e) => setLoginInput(e.currentTarget.value)}
-
/>
-
<button
-
onclick={() => login(loginInput())}
-
class="flex items-center rounded-md p-1 hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-600 dark:active:bg-neutral-500"
-
>
-
<span class="iconify lucide--log-in"></span>
-
</button>
-
</div>
-
<Show when={notice()}>
-
<div class="text-sm">{notice()}</div>
-
</Show>
-
</form>
-
);
-
};
-
-
const retrieveSession = async () => {
-
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 auth = await finalizeAuthorization(params);
-
const did = auth.session.info.sub;
-
-
localStorage.setItem("lastSignedIn", did);
-
-
const sessions = localStorage.getItem("sessions");
-
const newSessions: Sessions = sessions ? JSON.parse(sessions) : { [did]: {} };
-
newSessions[did] = { signedIn: true };
-
localStorage.setItem("sessions", JSON.stringify(newSessions));
-
return auth.session;
-
} else {
-
const lastSignedIn = localStorage.getItem("lastSignedIn");
-
-
if (lastSignedIn) {
-
const sessions = localStorage.getItem("sessions");
-
const newSessions: Sessions = sessions ? JSON.parse(sessions) : {};
-
try {
-
const session = await getSession(lastSignedIn as Did);
-
const rpc = new Client({ handler: new OAuthUserAgent(session) });
-
const res = await rpc.get("com.atproto.server.getSession");
-
newSessions[lastSignedIn].signedIn = true;
-
localStorage.setItem("sessions", JSON.stringify(newSessions));
-
if (!res.ok) throw res.data.error;
-
return session;
-
} catch (err) {
-
newSessions[lastSignedIn].signedIn = false;
-
localStorage.setItem("sessions", JSON.stringify(newSessions));
-
throw err;
-
}
-
}
-
}
-
};
-
-
const session = await init();
-
-
if (session) setAgent(new OAuthUserAgent(session));
-
};
-
-
export { Login, retrieveSession };
public/favicon.ico

This is a binary file and will not be displayed.

public/fonts/Figtree[wght].woff2

This is a binary file and will not be displayed.

+7 -1
src/styles/index.css
···
@custom-variant dark (&:where(.dark, .dark *));
+
@font-face {
+
font-family: "Figtree";
+
src: url("/fonts/Figtree[wght].woff2") format("woff2");
+
font-display: swap;
+
}
+
@theme {
-
--font-sans: "Inter", sans-serif;
+
--font-sans: "Figtree", sans-serif;
--font-mono: "Roboto Mono", monospace;
--font-pecita: "Pecita", serif;
+1
src/components/create.tsx
···
onMount(() => {
const keyEvent = (ev: KeyboardEvent) => {
if (ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement) return;
+
if ((ev.target as HTMLElement).closest("[data-modal]")) return;
const key = props.create ? "n" : "e";
if (ev.key === key) {
+109
src/components/create/file-upload.tsx
···
+
import { Client } from "@atcute/client";
+
import { remove } from "@mary/exif-rm";
+
import { createSignal, onCleanup, Show } from "solid-js";
+
import { agent } from "../../auth/state";
+
import { Button } from "../button.jsx";
+
import { TextInput } from "../text-input.jsx";
+
import { editorInstance } from "./state";
+
+
export const FileUpload = (props: {
+
file: File;
+
blobInput: HTMLInputElement;
+
onClose: () => void;
+
}) => {
+
const [uploading, setUploading] = createSignal(false);
+
const [error, setError] = createSignal("");
+
+
onCleanup(() => (props.blobInput.value = ""));
+
+
const formatFileSize = (bytes: number) => {
+
if (bytes === 0) return "0 Bytes";
+
const k = 1024;
+
const sizes = ["Bytes", "KB", "MB", "GB"];
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
+
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
+
};
+
+
const uploadBlob = async () => {
+
let blob: Blob;
+
+
const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value;
+
(document.getElementById("mimetype") as HTMLInputElement).value = "";
+
if (mimetype) blob = new Blob([props.file], { type: mimetype });
+
else blob = props.file;
+
+
if ((document.getElementById("exif-rm") as HTMLInputElement).checked) {
+
const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer()));
+
if (exifRemoved !== null) blob = new Blob([exifRemoved], { type: blob.type });
+
}
+
+
const rpc = new Client({ handler: agent()! });
+
setUploading(true);
+
const res = await rpc.post("com.atproto.repo.uploadBlob", {
+
input: blob,
+
});
+
setUploading(false);
+
if (!res.ok) {
+
setError(res.data.error);
+
return;
+
}
+
editorInstance.view.dispatch({
+
changes: {
+
from: editorInstance.view.state.selection.main.head,
+
insert: JSON.stringify(res.data.blob, null, 2),
+
},
+
});
+
props.onClose();
+
};
+
+
return (
+
<div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] w-[20rem] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0">
+
<h2 class="mb-2 font-semibold">Upload blob</h2>
+
<div class="flex flex-col gap-2 text-sm">
+
<div class="flex flex-col gap-1">
+
<p class="flex gap-1">
+
<span class="truncate">{props.file.name}</span>
+
<span class="shrink-0 text-neutral-600 dark:text-neutral-400">
+
({formatFileSize(props.file.size)})
+
</span>
+
</p>
+
</div>
+
<div class="flex items-center gap-x-2">
+
<label for="mimetype" class="shrink-0 select-none">
+
MIME type
+
</label>
+
<TextInput id="mimetype" placeholder={props.file.type} />
+
</div>
+
<div class="flex items-center gap-1">
+
<input id="exif-rm" type="checkbox" checked />
+
<label for="exif-rm" class="select-none">
+
Remove EXIF data
+
</label>
+
</div>
+
<p class="text-xs text-neutral-600 dark:text-neutral-400">
+
Metadata will be pasted after the cursor
+
</p>
+
<Show when={error()}>
+
<span class="text-red-500 dark:text-red-400">Error: {error()}</span>
+
</Show>
+
<div class="flex justify-between gap-2">
+
<Button onClick={props.onClose}>Cancel</Button>
+
<Show when={uploading()}>
+
<div class="flex items-center gap-1">
+
<span class="iconify lucide--loader-circle animate-spin"></span>
+
<span>Uploading</span>
+
</div>
+
</Show>
+
<Show when={!uploading()}>
+
<Button
+
onClick={uploadBlob}
+
class="dark:shadow-dark-700 flex items-center gap-1 rounded-lg bg-blue-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-500 dark:active:bg-blue-400"
+
>
+
Upload
+
</Button>
+
</Show>
+
</div>
+
</div>
+
</div>
+
);
+
};
+87
src/components/create/handle-input.tsx
···
+
import { Handle } from "@atcute/lexicons";
+
import { createSignal, Show } from "solid-js";
+
import { resolveHandle } from "../../utils/api";
+
import { Button } from "../button.jsx";
+
import { TextInput } from "../text-input.jsx";
+
import { editorInstance } from "./state";
+
+
export const HandleInput = (props: { onClose: () => void }) => {
+
const [resolving, setResolving] = createSignal(false);
+
const [error, setError] = createSignal("");
+
let handleFormRef!: HTMLFormElement;
+
+
const resolveDid = async (e: SubmitEvent) => {
+
e.preventDefault();
+
const formData = new FormData(handleFormRef);
+
const handleValue = formData.get("handle")?.toString().trim();
+
+
if (!handleValue) {
+
setError("Please enter a handle");
+
return;
+
}
+
+
setResolving(true);
+
setError("");
+
try {
+
const did = await resolveHandle(handleValue as Handle);
+
editorInstance.view.dispatch({
+
changes: {
+
from: editorInstance.view.state.selection.main.head,
+
insert: `"${did}"`,
+
},
+
});
+
props.onClose();
+
handleFormRef.reset();
+
} catch (err: any) {
+
setError(err.message || "Failed to resolve handle");
+
} finally {
+
setResolving(false);
+
}
+
};
+
+
return (
+
<div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] w-[20rem] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0">
+
<h2 class="mb-2 font-semibold">Insert DID from handle</h2>
+
<form ref={handleFormRef} onSubmit={resolveDid} class="flex flex-col gap-2 text-sm">
+
<div class="flex flex-col gap-1">
+
<label for="handle-input" class="select-none">
+
Handle
+
</label>
+
<TextInput id="handle-input" name="handle" placeholder="user.bsky.social" />
+
</div>
+
<p class="text-xs text-neutral-600 dark:text-neutral-400">
+
DID will be pasted after the cursor
+
</p>
+
<Show when={error()}>
+
<span class="text-red-500 dark:text-red-400">Error: {error()}</span>
+
</Show>
+
<div class="flex justify-between gap-2">
+
<Button
+
type="button"
+
onClick={() => {
+
props.onClose();
+
handleFormRef.reset();
+
setError("");
+
}}
+
>
+
Cancel
+
</Button>
+
<Show when={resolving()}>
+
<div class="flex items-center gap-1">
+
<span class="iconify lucide--loader-circle animate-spin"></span>
+
<span>Resolving</span>
+
</div>
+
</Show>
+
<Show when={!resolving()}>
+
<Button
+
type="submit"
+
class="dark:shadow-dark-700 flex items-center gap-1 rounded-lg bg-blue-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-500 dark:active:bg-blue-400"
+
>
+
Insert
+
</Button>
+
</Show>
+
</div>
+
</form>
+
</div>
+
);
+
};
+12
src/components/create/menu-item.tsx
···
+
export const MenuItem = (props: { icon: string; label: string; onClick: () => void }) => {
+
return (
+
<button
+
type="button"
+
class="flex items-center gap-2 rounded-md p-2 text-left text-xs hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
+
onClick={props.onClick}
+
>
+
<span class={`iconify ${props.icon}`}></span>
+
<span>{props.label}</span>
+
</button>
+
);
+
};
+4
src/components/create/state.ts
···
+
import { createSignal } from "solid-js";
+
+
export const editorInstance = { view: null as any };
+
export const [placeholder, setPlaceholder] = createSignal<any>();
+29 -48
src/components/create/index.tsx
···
import { addNotification, removeNotification } from "../notification.jsx";
import { TextInput } from "../text-input.jsx";
import Tooltip from "../tooltip.jsx";
+
import { ConfirmSubmit } from "./confirm-submit";
import { FileUpload } from "./file-upload";
import { HandleInput } from "./handle-input";
import { MenuItem } from "./menu-item";
···
const [openUpload, setOpenUpload] = createSignal(false);
const [openInsertMenu, setOpenInsertMenu] = createSignal(false);
const [openHandleDialog, setOpenHandleDialog] = createSignal(false);
-
const [validate, setValidate] = createSignal<boolean | undefined>(undefined);
+
const [openConfirmDialog, setOpenConfirmDialog] = createSignal(false);
const [isMaximized, setIsMaximized] = createSignal(false);
const [isMinimized, setIsMinimized] = createSignal(false);
const [collectionError, setCollectionError] = createSignal("");
···
};
};
-
const getValidateIcon = () => {
-
return (
-
validate() === true ? "lucide--circle-check"
-
: validate() === false ? "lucide--circle-x"
-
: "lucide--circle"
-
);
-
};
-
-
const getValidateLabel = () => {
-
return (
-
validate() === true ? "True"
-
: validate() === false ? "False"
-
: "Unset"
-
);
-
};
-
createEffect(() => {
if (openDialog()) {
-
setValidate(undefined);
setCollectionError("");
setRkeyError("");
}
});
-
const createRecord = async (formData: FormData) => {
+
const createRecord = async (validate: boolean | undefined) => {
+
const formData = new FormData(formRef);
const repo = formData.get("repo")?.toString();
if (!repo) return;
const rpc = new Client({ handler: new OAuthUserAgent(await getSession(repo as Did)) });
···
collection: collection ? collection.toString() : record.$type,
rkey: rkey?.toString().length ? rkey?.toString() : undefined,
record: record,
-
validate: validate(),
+
validate: validate,
},
});
if (!res.ok) {
setNotice(`${res.data.error}: ${res.data.message}`);
return;
}
+
setOpenConfirmDialog(false);
setOpenDialog(false);
const id = addNotification({
message: "Record created",
···
navigate(`/${res.data.uri}`);
};
-
const editRecord = async (recreate?: boolean) => {
+
const editRecord = async (validate: boolean | undefined, recreate: boolean) => {
const record = editorInstance.view.state.doc.toString();
if (!record) return;
const rpc = new Client({ handler: agent()! });
···
const res = await rpc.post("com.atproto.repo.applyWrites", {
input: {
repo: agent()!.sub,
-
validate: validate(),
+
validate: validate,
writes: [
{
collection: params.collection as `${string}.${string}.${string}`,
···
const res = await rpc.post("com.atproto.repo.applyWrites", {
input: {
repo: agent()!.sub,
-
validate: validate(),
+
validate: validate,
writes: [
{
collection: params.collection as `${string}.${string}.${string}`,
···
return;
}
}
+
setOpenConfirmDialog(false);
setOpenDialog(false);
const id = addNotification({
message: "Record edited",
···
>
<HandleInput onClose={() => setOpenHandleDialog(false)} />
</Modal>
+
<Modal
+
open={openConfirmDialog()}
+
onClose={() => setOpenConfirmDialog(false)}
+
closeOnClick={false}
+
>
+
<ConfirmSubmit
+
isCreate={props.create}
+
onConfirm={(validate, recreate) => {
+
if (props.create) {
+
createRecord(validate);
+
} else {
+
editRecord(validate, recreate);
+
}
+
}}
+
onClose={() => setOpenConfirmDialog(false)}
+
/>
+
</Modal>
<div class="flex items-center justify-end gap-2">
-
<button
-
type="button"
-
class="flex items-center gap-1 rounded-sm p-1.5 text-xs hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
-
onClick={() =>
-
setValidate(
-
validate() === true ? false
-
: validate() === false ? undefined
-
: true,
-
)
-
}
-
>
-
<Tooltip text={getValidateLabel()}>
-
<span class={`iconify ${getValidateIcon()}`}></span>
-
</Tooltip>
-
<span>Validate</span>
-
</button>
-
<Show when={!props.create && hasUserScope("create") && hasUserScope("delete")}>
-
<Button onClick={() => editRecord(true)}>Recreate</Button>
-
</Show>
-
<Button
-
onClick={() =>
-
props.create ? createRecord(new FormData(formRef)) : editRecord()
-
}
-
>
-
{props.create ? "Create" : "Edit"}
+
<Button onClick={() => setOpenConfirmDialog(true)}>
+
{props.create ? "Create..." : "Edit..."}
</Button>
</div>
</div>
+2 -2
src/components/create/confirm-submit.tsx
···
<div class="flex items-center gap-2">
<button
type="button"
-
class="-ml-2 flex min-w-30 items-center gap-1.5 rounded-lg px-2 py-1 text-xs hover:bg-neutral-200 dark:hover:bg-neutral-700"
+
class="-ml-2 flex min-w-30 items-center gap-1.5 rounded-lg px-2 py-1 text-xs hover:bg-neutral-200/50 dark:hover:bg-neutral-700"
onClick={cycleValidate}
>
<span
···
<div class="flex items-center gap-2">
<button
type="button"
-
class="-ml-2 flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs hover:bg-neutral-200 dark:hover:bg-neutral-700"
+
class="-ml-2 flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs hover:bg-neutral-200/50 dark:hover:bg-neutral-700"
onClick={() => setRecreate(!recreate())}
>
<span
+2 -2
src/components/sticky.tsx
···
/>
<div
-
class="sticky top-2 z-10 flex w-full flex-col items-center justify-center gap-2 rounded-lg p-3 transition-colors"
+
class="sticky top-2 z-10 flex w-full flex-col items-center justify-center gap-2 rounded-lg border-[0.5px] p-3 transition-colors"
classList={{
-
"bg-neutral-50 dark:bg-dark-300 border-[0.5px] border-neutral-300 dark:border-neutral-700 shadow-md":
+
"bg-neutral-50 dark:bg-dark-300 border-neutral-300 dark:border-neutral-700 shadow-md":
filterStuck(),
"bg-transparent border-transparent shadow-none": !filterStuck(),
}}
+1 -1
src/components/navbar.tsx
···
<A
end
href={`/at://${props.params.repo}`}
-
inactiveClass="flex min-w-0 gap-1 py-0.5 font-medium text-blue-400 hover:text-blue-500 transition-colors duration-150 dark:hover:text-blue-300"
+
inactiveClass="flex grow min-w-0 gap-1 py-0.5 font-medium text-blue-400 hover:text-blue-500 transition-colors duration-150 dark:hover:text-blue-300"
>
<Show
when={handle() !== props.params.repo}
+1 -1
src/auth/login.tsx
···
<label for="username" class="hidden">
Add account
</label>
-
<div class="dark:bg-dark-100 dark:inset-shadow-dark-200 flex grow items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 inset-shadow-xs focus-within:outline-[1px] focus-within:outline-neutral-600 dark:border-neutral-600 dark:focus-within:outline-neutral-400">
+
<div class="dark:bg-dark-100 flex grow items-center gap-2 rounded-lg bg-white px-2 outline-1 outline-neutral-200 focus-within:outline-[1.5px] focus-within:outline-neutral-600 dark:outline-neutral-600 dark:focus-within:outline-neutral-400">
<label
for="username"
class="iconify lucide--user-round-plus shrink-0 text-neutral-500 dark:text-neutral-400"
+1 -1
src/views/blob.tsx
···
return (
<div class="flex flex-col items-center gap-2">
<Show when={blobs() || response()}>
-
<div class="flex w-full flex-col gap-0.5 font-mono text-xs sm:text-sm">
+
<div class="flex w-full flex-col gap-0.5 pb-20 font-mono text-xs sm:text-sm">
<For each={blobs()}>
{(cid) => (
<a
+24
src/utils/route-cache.ts
···
+
import { createStore } from "solid-js/store";
+
+
export interface CollectionCacheEntry {
+
records: unknown[];
+
cursor: string | undefined;
+
scrollY: number;
+
reverse: boolean;
+
}
+
+
type RouteCache = Record<string, CollectionCacheEntry>;
+
+
const [routeCache, setRouteCache] = createStore<RouteCache>({});
+
+
export const getCollectionCache = (key: string): CollectionCacheEntry | undefined => {
+
return routeCache[key];
+
};
+
+
export const setCollectionCache = (key: string, entry: CollectionCacheEntry): void => {
+
setRouteCache(key, entry);
+
};
+
+
export const clearCollectionCache = (key: string): void => {
+
setRouteCache(key, undefined!);
+
};
+3 -1
src/components/lexicon-schema.tsx
···
<ConstraintsList property={props.property.items!} />
</Show>
<Show when={props.property.description && !props.hideNameType}>
-
<p class="text-sm text-neutral-700 dark:text-neutral-300">{props.property.description}</p>
+
<p class="text-sm wrap-break-word text-neutral-700 dark:text-neutral-300">
+
{props.property.description}
+
</p>
</Show>
</div>
);
public/ribbon.webp

This is a binary file and will not be displayed.

+6
src/components/search.tsx
···
input = input.trim().replace(/^@/, "");
if (!input.length) return;
+
if (input.includes("%")) {
+
try {
+
input = decodeURIComponent(input);
+
} catch {}
+
}
+
setShowSearch(false);
setInput(undefined);
setSelectedIndex(-1);
-1
index.html
···
<meta property="og:description" content="Browse the public data on atproto" />
<meta property="description" content="Browse the public data on atproto" />
<link rel="manifest" href="/manifest.json" />
-
<title>PDSls</title>
<link rel="preconnect" href="https://fonts.bunny.net" />
<link href="https://fonts.bunny.net/css?family=roboto-mono:400" rel="stylesheet" />
<link href="https://fonts.cdnfonts.com/css/pecita" rel="stylesheet" />
+157 -153
src/views/collection.tsx
···
import { Client, simpleFetchHandler } from "@atcute/client";
import { $type, ActorIdentifier, InferXRPCBodyOutput } from "@atcute/lexicons";
import * as TID from "@atcute/tid";
+
import { Title } from "@solidjs/meta";
import { A, useBeforeLeave, useParams } from "@solidjs/router";
import {
createEffect,
···
);
return (
-
<Show when={records.length || response()}>
-
<div class="-mt-2 flex w-full flex-col items-center">
-
<StickyOverlay>
-
<div class="flex w-full flex-col gap-2">
-
<div class="flex items-center gap-1.5">
-
<Show when={agent() && agent()?.sub === did && hasUserScope("delete")}>
-
<div class="flex items-center">
-
<Tooltip
-
text={batchDelete() ? "Cancel" : "Delete"}
-
children={
-
<button
-
onclick={() => {
-
setRecords({ from: 0, to: records.length - 1 }, "toDelete", false);
-
setLastSelected(undefined);
-
setBatchDelete(!batchDelete());
-
}}
-
class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
-
>
-
<span
-
class={`iconify ${batchDelete() ? "lucide--circle-x" : "lucide--trash-2"} `}
-
></span>
-
</button>
-
}
-
/>
-
<Show when={batchDelete()}>
+
<>
+
<Title>{params.collection} - PDSls</Title>
+
<Show when={records.length || response()}>
+
<div class="-mt-2 flex w-full flex-col items-center">
+
<StickyOverlay>
+
<div class="flex w-full flex-col gap-2">
+
<div class="flex items-center gap-1.5">
+
<Show when={agent() && agent()?.sub === did && hasUserScope("delete")}>
+
<div class="flex items-center">
<Tooltip
-
text="Select all"
+
text={batchDelete() ? "Cancel" : "Delete"}
children={
<button
-
onclick={() => selectAll()}
+
onclick={() => {
+
setRecords({ from: 0, to: records.length - 1 }, "toDelete", false);
+
setLastSelected(undefined);
+
setBatchDelete(!batchDelete());
+
}}
class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
>
-
<span class="iconify lucide--copy-check"></span>
+
<span
+
class={`iconify ${batchDelete() ? "lucide--circle-x" : "lucide--trash-2"} `}
+
></span>
</button>
}
/>
-
<Show when={hasUserScope("create")}>
+
<Show when={batchDelete()}>
<Tooltip
-
text="Recreate"
+
text="Select all"
+
children={
+
<button
+
onclick={() => selectAll()}
+
class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
+
>
+
<span class="iconify lucide--copy-check"></span>
+
</button>
+
}
+
/>
+
<Show when={hasUserScope("create")}>
+
<Tooltip
+
text="Recreate"
+
children={
+
<button
+
onclick={() => {
+
setRecreate(true);
+
setOpenDelete(true);
+
}}
+
class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
+
>
+
<span class="iconify lucide--recycle text-green-500 dark:text-green-400"></span>
+
</button>
+
}
+
/>
+
</Show>
+
<Tooltip
+
text="Delete"
children={
<button
onclick={() => {
-
setRecreate(true);
+
setRecreate(false);
setOpenDelete(true);
}}
class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
>
-
<span class="iconify lucide--recycle text-green-500 dark:text-green-400"></span>
+
<span class="iconify lucide--trash-2 text-red-500 dark:text-red-400"></span>
</button>
}
/>
</Show>
-
<Tooltip
-
text="Delete"
-
children={
-
<button
-
onclick={() => {
-
setRecreate(false);
-
setOpenDelete(true);
-
}}
-
class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
+
</div>
+
<Modal open={openDelete()} onClose={() => setOpenDelete(false)}>
+
<div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0">
+
<h2 class="mb-2 font-semibold">
+
{recreate() ? "Recreate" : "Delete"}{" "}
+
{records.filter((r) => r.toDelete).length} records?
+
</h2>
+
<div class="flex justify-end gap-2">
+
<Button onClick={() => setOpenDelete(false)}>Cancel</Button>
+
<Button
+
onClick={deleteRecords}
+
class={`dark:shadow-dark-700 rounded-lg px-2 py-1.5 text-xs text-white shadow-xs select-none ${recreate() ? "bg-green-500 hover:bg-green-400 dark:bg-green-600 dark:hover:bg-green-500" : "bg-red-500 hover:bg-red-400 active:bg-red-400"}`}
>
-
<span class="iconify lucide--trash-2 text-red-500 dark:text-red-400"></span>
-
</button>
-
}
-
/>
-
</Show>
-
</div>
-
<Modal open={openDelete()} onClose={() => setOpenDelete(false)}>
-
<div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0">
-
<h2 class="mb-2 font-semibold">
-
{recreate() ? "Recreate" : "Delete"}{" "}
-
{records.filter((r) => r.toDelete).length} records?
-
</h2>
-
<div class="flex justify-end gap-2">
-
<Button onClick={() => setOpenDelete(false)}>Cancel</Button>
-
<Button
-
onClick={deleteRecords}
-
class={`dark:shadow-dark-700 rounded-lg px-2 py-1.5 text-xs text-white shadow-xs select-none ${recreate() ? "bg-green-500 hover:bg-green-400 dark:bg-green-600 dark:hover:bg-green-500" : "bg-red-500 hover:bg-red-400 active:bg-red-400"}`}
-
>
-
{recreate() ? "Recreate" : "Delete"}
-
</Button>
+
{recreate() ? "Recreate" : "Delete"}
+
</Button>
+
</div>
</div>
+
</Modal>
+
</Show>
+
<TextInput
+
name="Filter"
+
placeholder="Filter by substring"
+
onInput={(e) => setFilter(e.currentTarget.value)}
+
class="grow"
+
/>
+
<Tooltip text="Jetstream">
+
<A
+
href={`/jetstream?collections=${params.collection}&dids=${params.repo}`}
+
class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
+
>
+
<span class="iconify lucide--radio-tower"></span>
+
</A>
+
</Tooltip>
+
</div>
+
<Show when={records.length > 1}>
+
<div class="flex items-center justify-between gap-x-2">
+
<Button
+
onClick={() => {
+
setReverse(!reverse());
+
setCursor(undefined);
+
clearCollectionCache(cacheKey());
+
refetch();
+
}}
+
classList={{
+
"text-blue-500! dark:text-blue-400! border-blue-500! dark:border-blue-400!":
+
reverse(),
+
}}
+
>
+
<span
+
class={`iconify ${reverse() ? "lucide--arrow-down-wide-narrow" : "lucide--arrow-up-narrow-wide"}`}
+
></span>
+
Reverse
+
</Button>
+
<div>
+
<Show when={batchDelete()}>
+
<span>{records.filter((rec) => rec.toDelete).length}</span>
+
<span>/</span>
+
</Show>
+
<span>{filter() ? filteredRecords().length : records.length} records</span>
+
</div>
+
<div class="flex w-20 items-center justify-end">
+
<Show when={cursor()}>
+
<Show when={!response.loading}>
+
<Button onClick={() => refetch()}>Load More</Button>
+
</Show>
+
<Show when={response.loading}>
+
<div class="iconify lucide--loader-circle w-20 animate-spin text-xl" />
+
</Show>
+
</Show>
</div>
-
</Modal>
+
</div>
</Show>
-
<TextInput
-
name="Filter"
-
placeholder="Filter by substring"
-
onInput={(e) => setFilter(e.currentTarget.value)}
-
class="grow"
-
/>
-
<Tooltip text="Jetstream">
-
<A
-
href={`/jetstream?collections=${params.collection}&dids=${params.repo}`}
-
class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
-
>
-
<span class="iconify lucide--radio-tower"></span>
-
</A>
-
</Tooltip>
</div>
-
<Show when={records.length > 1}>
-
<div class="flex items-center justify-between gap-x-2">
-
<Button
-
onClick={() => {
-
setReverse(!reverse());
-
setCursor(undefined);
-
clearCollectionCache(cacheKey());
-
refetch();
-
}}
-
classList={{
-
"text-blue-500! dark:text-blue-400! border-blue-500! dark:border-blue-400!":
-
reverse(),
-
}}
-
>
-
<span
-
class={`iconify ${reverse() ? "lucide--arrow-down-wide-narrow" : "lucide--arrow-up-narrow-wide"}`}
-
></span>
-
Reverse
-
</Button>
-
<div>
-
<Show when={batchDelete()}>
-
<span>{records.filter((rec) => rec.toDelete).length}</span>
-
<span>/</span>
-
</Show>
-
<span>{filter() ? filteredRecords().length : records.length} records</span>
-
</div>
-
<div class="flex w-20 items-center justify-end">
-
<Show when={cursor()}>
-
<Show when={!response.loading}>
-
<Button onClick={() => refetch()}>Load More</Button>
+
</StickyOverlay>
+
<div class="flex max-w-full flex-col px-2 font-mono">
+
<For each={filteredRecords()}>
+
{(record, index) => {
+
const rounding = () => {
+
const recs = filteredRecords();
+
const prevSelected = recs[index() - 1]?.toDelete;
+
const nextSelected = recs[index() + 1]?.toDelete;
+
return `${!prevSelected ? "rounded-t" : ""} ${!nextSelected ? "rounded-b" : ""}`;
+
};
+
return (
+
<>
+
<Show when={batchDelete()}>
+
<div
+
class={`select-none ${
+
record.toDelete ?
+
`bg-blue-200 hover:bg-blue-300/80 active:bg-blue-300 dark:bg-blue-700/30 dark:hover:bg-blue-700/50 dark:active:bg-blue-700/70 ${rounding()}`
+
: "rounded hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
+
}`}
+
onclick={(e) => {
+
handleSelectionClick(e, index());
+
setRecords(index(), "toDelete", !record.toDelete);
+
}}
+
>
+
<RecordLink record={record} />
+
</div>
</Show>
-
<Show when={response.loading}>
-
<div class="iconify lucide--loader-circle w-20 animate-spin text-xl" />
+
<Show when={!batchDelete()}>
+
<A
+
href={`/at://${did}/${params.collection}/${record.rkey}`}
+
class="rounded select-none hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
+
>
+
<RecordLink record={record} />
+
</A>
</Show>
-
</Show>
-
</div>
-
</div>
-
</Show>
+
</>
+
);
+
}}
+
</For>
</div>
-
</StickyOverlay>
-
<div class="flex max-w-full flex-col px-2 font-mono">
-
<For each={filteredRecords()}>
-
{(record, index) => {
-
const rounding = () => {
-
const recs = filteredRecords();
-
const prevSelected = recs[index() - 1]?.toDelete;
-
const nextSelected = recs[index() + 1]?.toDelete;
-
return `${!prevSelected ? "rounded-t" : ""} ${!nextSelected ? "rounded-b" : ""}`;
-
};
-
return (
-
<>
-
<Show when={batchDelete()}>
-
<div
-
class={`select-none ${
-
record.toDelete ?
-
`bg-blue-200 hover:bg-blue-300/80 active:bg-blue-300 dark:bg-blue-700/30 dark:hover:bg-blue-700/50 dark:active:bg-blue-700/70 ${rounding()}`
-
: "rounded hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
-
}`}
-
onclick={(e) => {
-
handleSelectionClick(e, index());
-
setRecords(index(), "toDelete", !record.toDelete);
-
}}
-
>
-
<RecordLink record={record} />
-
</div>
-
</Show>
-
<Show when={!batchDelete()}>
-
<A
-
href={`/at://${did}/${params.collection}/${record.rkey}`}
-
class="rounded select-none hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
-
>
-
<RecordLink record={record} />
-
</A>
-
</Show>
-
</>
-
);
-
}}
-
</For>
</div>
-
</div>
-
</Show>
+
</Show>
+
</>
);
};
+117 -113
src/views/labels.tsx
···
import { Client, simpleFetchHandler } from "@atcute/client";
import { isAtprotoDid } from "@atcute/identity";
import { Handle } from "@atcute/lexicons";
+
import { Title } from "@solidjs/meta";
import { A, useSearchParams } from "@solidjs/router";
import { createMemo, createSignal, For, onMount, Show } from "solid-js";
import { Button } from "../components/button.jsx";
···
};
return (
-
<div class="flex w-full flex-col items-center">
-
<form
-
ref={formRef}
-
class="flex w-full max-w-3xl flex-col gap-y-2 px-3 pb-2"
-
onSubmit={(e) => {
-
e.preventDefault();
-
handleSearch();
-
}}
-
>
-
<div class="flex flex-col gap-y-1.5">
-
<label class="flex w-full flex-col gap-y-1">
-
<span class="text-sm font-medium text-neutral-700 dark:text-neutral-300">
-
Labeler DID/Handle
-
</span>
-
<TextInput
-
name="did"
-
value={didInput()}
-
onInput={(e) => setDidInput(e.currentTarget.value)}
-
placeholder="did:plc:..."
-
class="w-full"
-
/>
-
</label>
-
-
<label class="flex w-full flex-col gap-y-1">
-
<span class="text-sm font-medium text-neutral-700 dark:text-neutral-300">
-
URI Patterns (comma-separated)
-
</span>
-
<textarea
-
id="uriPatterns"
-
name="uriPatterns"
-
spellcheck={false}
-
rows={2}
-
value={searchParams.uriPatterns ?? "*"}
-
placeholder="at://did:web:example.com/app.bsky.feed.post/*"
-
class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1.5 text-sm outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400"
-
/>
-
</label>
-
</div>
-
-
<Button
-
type="submit"
-
disabled={loading()}
-
class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 w-fit items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"
+
<>
+
<Title>Labels - PDSls</Title>
+
<div class="flex w-full flex-col items-center">
+
<form
+
ref={formRef}
+
class="flex w-full max-w-3xl flex-col gap-y-2 px-3 pb-2"
+
onSubmit={(e) => {
+
e.preventDefault();
+
handleSearch();
+
}}
>
-
<span class="iconify lucide--search" />
-
<span>Search Labels</span>
-
</Button>
-
-
<Show when={error()}>
-
<div class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
-
{error()}
-
</div>
-
</Show>
-
</form>
-
-
<Show when={hasSearched()}>
-
<StickyOverlay>
-
<div class="flex w-full items-center gap-x-2">
-
<TextInput
-
placeholder="Filter labels (* for partial, -exclude)"
-
name="filter"
-
value={filter()}
-
onInput={(e) => setFilter(e.currentTarget.value)}
-
class="min-w-0 grow text-sm placeholder:text-xs"
-
/>
-
<div class="flex shrink-0 items-center gap-x-2 text-sm">
-
<Show when={labels().length > 0}>
-
<span class="whitespace-nowrap text-neutral-600 dark:text-neutral-400">
-
{filteredLabels().length}/{labels().length}
-
</span>
-
</Show>
-
-
<Show when={cursor()}>
-
<Button
-
onClick={handleLoadMore}
-
disabled={loading()}
-
class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 w-20 items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"
-
>
-
<Show
-
when={!loading()}
-
fallback={<span class="iconify lucide--loader-circle animate-spin" />}
-
>
-
Load More
-
</Show>
-
</Button>
-
</Show>
-
</div>
+
<div class="flex flex-col gap-y-1.5">
+
<label class="flex w-full flex-col gap-y-1">
+
<span class="text-sm font-medium text-neutral-700 dark:text-neutral-300">
+
Labeler DID/Handle
+
</span>
+
<TextInput
+
name="did"
+
value={didInput()}
+
onInput={(e) => setDidInput(e.currentTarget.value)}
+
placeholder="did:plc:..."
+
class="w-full"
+
/>
+
</label>
+
+
<label class="flex w-full flex-col gap-y-1">
+
<span class="text-sm font-medium text-neutral-700 dark:text-neutral-300">
+
URI Patterns (comma-separated)
+
</span>
+
<textarea
+
id="uriPatterns"
+
name="uriPatterns"
+
spellcheck={false}
+
rows={2}
+
value={searchParams.uriPatterns ?? "*"}
+
placeholder="at://did:web:example.com/app.bsky.feed.post/*"
+
class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1.5 text-sm outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400"
+
/>
+
</label>
</div>
-
</StickyOverlay>
-
<div class="w-full max-w-3xl px-3 py-2">
-
<Show when={loading() && labels().length === 0}>
-
<div class="flex flex-col items-center justify-center py-12 text-center">
-
<span class="iconify lucide--loader-circle mb-3 animate-spin text-4xl text-neutral-400" />
-
<p class="text-sm text-neutral-600 dark:text-neutral-400">Loading labels...</p>
+
<Button
+
type="submit"
+
disabled={loading()}
+
class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 w-fit items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"
+
>
+
<span class="iconify lucide--search" />
+
<span>Search Labels</span>
+
</Button>
+
+
<Show when={error()}>
+
<div class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
+
{error()}
</div>
</Show>
-
-
<Show when={!loading() || labels().length > 0}>
-
<Show when={filteredLabels().length > 0}>
-
<div class="grid gap-2">
-
<For each={filteredLabels()}>{(label) => <LabelCard label={label} />}</For>
+
</form>
+
+
<Show when={hasSearched()}>
+
<StickyOverlay>
+
<div class="flex w-full items-center gap-x-2">
+
<TextInput
+
placeholder="Filter labels (* for partial, -exclude)"
+
name="filter"
+
value={filter()}
+
onInput={(e) => setFilter(e.currentTarget.value)}
+
class="min-w-0 grow text-sm placeholder:text-xs"
+
/>
+
<div class="flex shrink-0 items-center gap-x-2 text-sm">
+
<Show when={labels().length > 0}>
+
<span class="whitespace-nowrap text-neutral-600 dark:text-neutral-400">
+
{filteredLabels().length}/{labels().length}
+
</span>
+
</Show>
+
+
<Show when={cursor()}>
+
<Button
+
onClick={handleLoadMore}
+
disabled={loading()}
+
class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 w-20 items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"
+
>
+
<Show
+
when={!loading()}
+
fallback={<span class="iconify lucide--loader-circle animate-spin" />}
+
>
+
Load More
+
</Show>
+
</Button>
+
</Show>
</div>
-
</Show>
+
</div>
+
</StickyOverlay>
-
<Show when={labels().length > 0 && filteredLabels().length === 0}>
-
<div class="flex flex-col items-center justify-center py-8 text-center">
-
<span class="iconify lucide--search-x mb-2 text-3xl text-neutral-400" />
-
<p class="text-sm text-neutral-600 dark:text-neutral-400">
-
No labels match your filter
-
</p>
+
<div class="w-full max-w-3xl px-3 py-2">
+
<Show when={loading() && labels().length === 0}>
+
<div class="flex flex-col items-center justify-center py-12 text-center">
+
<span class="iconify lucide--loader-circle mb-3 animate-spin text-4xl text-neutral-400" />
+
<p class="text-sm text-neutral-600 dark:text-neutral-400">Loading labels...</p>
</div>
</Show>
-
<Show when={labels().length === 0 && !loading()}>
-
<div class="flex flex-col items-center justify-center py-8 text-center">
-
<span class="iconify lucide--tags mb-2 text-3xl text-neutral-400" />
-
<p class="text-sm text-neutral-600 dark:text-neutral-400">No labels found</p>
-
</div>
+
<Show when={!loading() || labels().length > 0}>
+
<Show when={filteredLabels().length > 0}>
+
<div class="grid gap-2">
+
<For each={filteredLabels()}>{(label) => <LabelCard label={label} />}</For>
+
</div>
+
</Show>
+
+
<Show when={labels().length > 0 && filteredLabels().length === 0}>
+
<div class="flex flex-col items-center justify-center py-8 text-center">
+
<span class="iconify lucide--search-x mb-2 text-3xl text-neutral-400" />
+
<p class="text-sm text-neutral-600 dark:text-neutral-400">
+
No labels match your filter
+
</p>
+
</div>
+
</Show>
+
+
<Show when={labels().length === 0 && !loading()}>
+
<div class="flex flex-col items-center justify-center py-8 text-center">
+
<span class="iconify lucide--tags mb-2 text-3xl text-neutral-400" />
+
<p class="text-sm text-neutral-600 dark:text-neutral-400">No labels found</p>
+
</div>
+
</Show>
</Show>
-
</Show>
-
</div>
-
</Show>
-
</div>
+
</div>
+
</Show>
+
</div>
+
</>
);
};
+180 -169
src/views/record.tsx
···
<Title>
{params.collection}/{params.rkey} - PDSls
</Title>
-
<Show when={record()} keyed>
-
<div class="flex w-full flex-col items-center">
-
<div class="mb-3 flex w-full justify-between px-2 text-sm sm:text-base">
-
<div class="flex items-center gap-4">
-
<RecordTab tab="record" label="Record" />
-
<RecordTab tab="schema" label="Schema" />
-
<RecordTab tab="backlinks" label="Backlinks" />
-
<RecordTab tab="info" label="Info" error />
+
<ErrorBoundary
+
fallback={(err) => (
+
<div class="flex w-full flex-col items-center gap-1 px-2 py-4">
+
<span class="font-semibold text-red-500 dark:text-red-400">Error loading record</span>
+
<div class="max-w-md text-sm wrap-break-word text-neutral-600 dark:text-neutral-400">
+
{err.message}
</div>
-
<div class="flex gap-0.5">
-
<Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}>
-
<Show when={hasUserScope("update")}>
-
<RecordEditor create={false} record={record()?.value} refetch={refetch} />
-
</Show>
-
<Show when={hasUserScope("delete")}>
-
<Tooltip text="Delete">
-
<button
-
class="flex items-center rounded-sm p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
-
onclick={() => setOpenDelete(true)}
-
>
-
<span class="iconify lucide--trash-2"></span>
-
</button>
-
</Tooltip>
-
<Modal open={openDelete()} onClose={() => setOpenDelete(false)}>
-
<div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0">
-
<h2 class="mb-2 font-semibold">Delete this record?</h2>
-
<div class="flex justify-end gap-2">
-
<Button onClick={() => setOpenDelete(false)}>Cancel</Button>
-
<Button
-
onClick={deleteRecord}
-
class="dark:shadow-dark-700 rounded-lg bg-red-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-red-400 active:bg-red-400"
-
>
-
Delete
-
</Button>
-
</div>
-
</div>
-
</Modal>
-
</Show>
-
</Show>
-
<MenuProvider>
-
<DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-sm p-1.5">
-
<CopyMenu
-
content={JSON.stringify(record()?.value, null, 2)}
-
label="Copy record"
-
icon="lucide--copy"
-
/>
-
<CopyMenu
-
content={`at://${params.repo}/${params.collection}/${params.rkey}`}
-
label="Copy AT URI"
-
icon="lucide--copy"
-
/>
-
<Show when={record()?.cid}>
-
{(cid) => <CopyMenu content={cid()} label="Copy CID" icon="lucide--copy" />}
+
</div>
+
)}
+
>
+
<Show when={record()} keyed>
+
<div class="flex w-full flex-col items-center">
+
<div class="mb-3 flex w-full justify-between px-2 text-sm sm:text-base">
+
<div class="flex items-center gap-4">
+
<RecordTab tab="record" label="Record" />
+
<RecordTab tab="schema" label="Schema" />
+
<RecordTab tab="backlinks" label="Backlinks" />
+
<RecordTab tab="info" label="Info" error />
+
</div>
+
<div class="flex gap-0.5">
+
<Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}>
+
<Show when={hasUserScope("update")}>
+
<RecordEditor create={false} record={record()?.value} refetch={refetch} />
</Show>
-
<MenuSeparator />
-
<Show when={externalLink()}>
-
{(externalLink) => (
-
<NavMenu
-
href={externalLink()?.link}
-
icon={`${externalLink().icon ?? "lucide--app-window"}`}
-
label={`Open on ${externalLink().label}`}
-
newTab
-
/>
-
)}
+
<Show when={hasUserScope("delete")}>
+
<Tooltip text="Delete">
+
<button
+
class="flex items-center rounded-sm p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
+
onclick={() => setOpenDelete(true)}
+
>
+
<span class="iconify lucide--trash-2"></span>
+
</button>
+
</Tooltip>
+
<Modal open={openDelete()} onClose={() => setOpenDelete(false)}>
+
<div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0">
+
<h2 class="mb-2 font-semibold">Delete this record?</h2>
+
<div class="flex justify-end gap-2">
+
<Button onClick={() => setOpenDelete(false)}>Cancel</Button>
+
<Button
+
onClick={deleteRecord}
+
class="dark:shadow-dark-700 rounded-lg bg-red-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-red-400 active:bg-red-400"
+
>
+
Delete
+
</Button>
+
</div>
+
</div>
+
</Modal>
</Show>
-
<NavMenu
-
href={`https://${pds()}/xrpc/com.atproto.repo.getRecord?repo=${params.repo}&collection=${params.collection}&rkey=${params.rkey}`}
-
icon="lucide--external-link"
-
label="Record on PDS"
-
newTab
-
/>
-
</DropdownMenu>
-
</MenuProvider>
-
</div>
-
</div>
-
<Show when={!location.hash || location.hash === "#record"}>
-
<div class="w-max max-w-screen min-w-full px-4 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:px-2 sm:text-sm md:max-w-3xl">
-
<JSONValue data={record()?.value as any} repo={record()!.uri.split("/")[2]} />
+
</Show>
+
<MenuProvider>
+
<DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-sm p-1.5">
+
<CopyMenu
+
content={JSON.stringify(record()?.value, null, 2)}
+
label="Copy record"
+
icon="lucide--copy"
+
/>
+
<CopyMenu
+
content={`at://${params.repo}/${params.collection}/${params.rkey}`}
+
label="Copy AT URI"
+
icon="lucide--copy"
+
/>
+
<Show when={record()?.cid}>
+
{(cid) => <CopyMenu content={cid()} label="Copy CID" icon="lucide--copy" />}
+
</Show>
+
<MenuSeparator />
+
<Show when={externalLink()}>
+
{(externalLink) => (
+
<NavMenu
+
href={externalLink()?.link}
+
icon={`${externalLink().icon ?? "lucide--app-window"}`}
+
label={`Open on ${externalLink().label}`}
+
newTab
+
/>
+
)}
+
</Show>
+
<NavMenu
+
href={`https://${pds()}/xrpc/com.atproto.repo.getRecord?repo=${params.repo}&collection=${params.collection}&rkey=${params.rkey}`}
+
icon="lucide--external-link"
+
label="Record on PDS"
+
newTab
+
/>
+
</DropdownMenu>
+
</MenuProvider>
+
</div>
</div>
-
</Show>
-
<Show when={location.hash === "#schema" || location.hash.startsWith("#schema:")}>
-
<Show when={lexiconNotFound() === true}>
-
<span class="w-full px-2 text-sm">Lexicon schema could not be resolved.</span>
+
<Show when={!location.hash || location.hash === "#record"}>
+
<div class="w-max max-w-screen min-w-full px-4 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:px-2 sm:text-sm md:max-w-3xl">
+
<JSONValue data={record()?.value as any} repo={record()!.uri.split("/")[2]} />
+
</div>
</Show>
-
<Show when={lexiconNotFound() === undefined}>
-
<span class="w-full px-2 text-sm">Resolving lexicon schema...</span>
+
<Show when={location.hash === "#schema" || location.hash.startsWith("#schema:")}>
+
<Show when={lexiconNotFound() === true}>
+
<span class="w-full px-2 text-sm">Lexicon schema could not be resolved.</span>
+
</Show>
+
<Show when={lexiconNotFound() === undefined}>
+
<span class="w-full px-2 text-sm">Resolving lexicon schema...</span>
+
</Show>
+
<Show when={schema() || params.collection === "com.atproto.lexicon.schema"}>
+
<ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}>
+
<LexiconSchemaView schema={schema()?.rawSchema ?? (record()?.value as any)} />
+
</ErrorBoundary>
+
</Show>
</Show>
-
<Show when={schema() || params.collection === "com.atproto.lexicon.schema"}>
-
<ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}>
-
<LexiconSchemaView schema={schema()?.rawSchema ?? (record()?.value as any)} />
+
<Show when={location.hash === "#backlinks"}>
+
<ErrorBoundary
+
fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>}
+
>
+
<Suspense
+
fallback={
+
<div class="iconify lucide--loader-circle animate-spin self-center text-xl" />
+
}
+
>
+
<div class="w-full px-2">
+
<Backlinks target={`at://${did}/${params.collection}/${params.rkey}`} />
+
</div>
+
</Suspense>
</ErrorBoundary>
</Show>
-
</Show>
-
<Show when={location.hash === "#backlinks"}>
-
<ErrorBoundary
-
fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>}
-
>
-
<Suspense
-
fallback={
-
<div class="iconify lucide--loader-circle animate-spin self-center text-xl" />
-
}
-
>
-
<div class="w-full px-2">
-
<Backlinks target={`at://${did}/${params.collection}/${params.rkey}`} />
-
</div>
-
</Suspense>
-
</ErrorBoundary>
-
</Show>
-
<Show when={location.hash === "#info"}>
-
<div class="flex w-full flex-col gap-2 px-2 text-sm">
-
<div>
-
<p class="font-semibold">AT URI</p>
-
<div class="truncate text-xs">{record()?.uri}</div>
-
</div>
-
<Show when={record()?.cid}>
+
<Show when={location.hash === "#info"}>
+
<div class="flex w-full flex-col gap-2 px-2 text-sm">
<div>
-
<p class="font-semibold">CID</p>
-
<div class="truncate text-left text-xs" dir="rtl">
-
{record()?.cid}
-
</div>
+
<p class="font-semibold">AT URI</p>
+
<div class="truncate text-xs">{record()?.uri}</div>
</div>
-
</Show>
-
<div>
-
<div class="flex items-center gap-1">
-
<p class="font-semibold">Record verification</p>
-
<span
-
classList={{
-
"iconify lucide--check text-green-500 dark:text-green-400":
-
validRecord() === true,
-
"iconify lucide--x text-red-500 dark:text-red-400": validRecord() === false,
-
"iconify lucide--loader-circle animate-spin": validRecord() === undefined,
-
}}
-
></span>
-
</div>
-
<Show when={validRecord() === false}>
-
<div class="text-xs wrap-break-word">{verifyError()}</div>
+
<Show when={record()?.cid}>
+
<div>
+
<p class="font-semibold">CID</p>
+
<div class="truncate text-left text-xs" dir="rtl">
+
{record()?.cid}
+
</div>
+
</div>
</Show>
-
</div>
-
<div>
-
<div class="flex items-center gap-1">
-
<p class="font-semibold">Schema validation</p>
-
<span
-
classList={{
-
"iconify lucide--check text-green-500 dark:text-green-400":
-
validSchema() === true,
-
"iconify lucide--x text-red-500 dark:text-red-400": validSchema() === false,
-
"iconify lucide--loader-circle animate-spin":
-
validSchema() === undefined && remoteValidation(),
-
}}
-
></span>
+
<div>
+
<div class="flex items-center gap-1">
+
<p class="font-semibold">Record verification</p>
+
<span
+
classList={{
+
"iconify lucide--check text-green-500 dark:text-green-400":
+
validRecord() === true,
+
"iconify lucide--x text-red-500 dark:text-red-400": validRecord() === false,
+
"iconify lucide--loader-circle animate-spin": validRecord() === undefined,
+
}}
+
></span>
+
</div>
+
<Show when={validRecord() === false}>
+
<div class="text-xs wrap-break-word">{verifyError()}</div>
+
</Show>
</div>
-
<Show when={validSchema() === false}>
-
<div class="text-xs wrap-break-word">{validationError()}</div>
-
</Show>
-
<Show
-
when={
-
!remoteValidation() &&
-
validSchema() === undefined &&
-
params.collection &&
-
!(params.collection in lexicons)
-
}
-
>
-
<Button onClick={() => validateRemoteSchema(record()!.value)}>
-
Validate via resolution
-
</Button>
-
</Show>
-
</div>
-
<Show when={lexiconUri()}>
<div>
-
<p class="font-semibold">Lexicon schema</p>
-
<div class="truncate text-xs">
-
<A
-
href={`/${lexiconUri()}`}
-
class="text-blue-400 hover:underline active:underline"
-
>
-
{lexiconUri()}
-
</A>
+
<div class="flex items-center gap-1">
+
<p class="font-semibold">Schema validation</p>
+
<span
+
classList={{
+
"iconify lucide--check text-green-500 dark:text-green-400":
+
validSchema() === true,
+
"iconify lucide--x text-red-500 dark:text-red-400": validSchema() === false,
+
"iconify lucide--loader-circle animate-spin":
+
validSchema() === undefined && remoteValidation(),
+
}}
+
></span>
</div>
+
<Show when={validSchema() === false}>
+
<div class="text-xs wrap-break-word">{validationError()}</div>
+
</Show>
+
<Show
+
when={
+
!remoteValidation() &&
+
validSchema() === undefined &&
+
params.collection &&
+
!(params.collection in lexicons)
+
}
+
>
+
<Button onClick={() => validateRemoteSchema(record()!.value)}>
+
Validate via resolution
+
</Button>
+
</Show>
</div>
-
</Show>
-
</div>
-
</Show>
-
</div>
-
</Show>
+
<Show when={lexiconUri()}>
+
<div>
+
<p class="font-semibold">Lexicon schema</p>
+
<div class="truncate text-xs">
+
<A
+
href={`/${lexiconUri()}`}
+
class="text-blue-400 hover:underline active:underline"
+
>
+
{lexiconUri()}
+
</A>
+
</div>
+
</div>
+
</Show>
+
</div>
+
</Show>
+
</div>
+
</Show>
+
</ErrorBoundary>
</>
);
};
+1 -1
src/components/tooltip.tsx
···
<Show when={!isTouchDevice}>
<span
style={`transform: translate(-50%, 28px)`}
-
class={`dark:shadow-dark-700 pointer-events-none absolute left-[50%] z-20 hidden min-w-fit rounded border-[0.5px] border-neutral-300 bg-neutral-50 p-1 text-center font-sans text-xs whitespace-nowrap text-neutral-900 shadow-md select-none group-hover/tooltip:inline first-letter:capitalize dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-200`}
+
class={`dark:shadow-dark-700 dark:bg-dark-300 pointer-events-none absolute left-[50%] z-20 hidden min-w-fit rounded border-[0.5px] border-neutral-300 bg-white p-1 text-center font-sans text-xs whitespace-nowrap text-neutral-900 shadow-md select-none group-hover/tooltip:inline first-letter:capitalize dark:border-neutral-600 dark:text-neutral-200`}
>
{props.text}
</span>
+3
package.json
···
"dependencies": {
"@atcute/atproto": "^3.1.10",
"@atcute/bluesky": "^3.2.14",
+
"@atcute/car": "^5.0.0",
+
"@atcute/cbor": "^2.2.8",
+
"@atcute/cid": "^2.3.0",
"@atcute/client": "^4.2.0",
"@atcute/crypto": "^2.3.0",
"@atcute/did-plc": "^0.3.1",
+9
pnpm-lock.yaml
···
'@atcute/bluesky':
specifier: ^3.2.14
version: 3.2.14
+
'@atcute/car':
+
specifier: ^5.0.0
+
version: 5.0.0
+
'@atcute/cbor':
+
specifier: ^2.2.8
+
version: 2.2.8
+
'@atcute/cid':
+
specifier: ^2.3.0
+
version: 2.3.0
'@atcute/client':
specifier: ^4.2.0
version: 4.2.0
+2
src/index.tsx
···
import { render } from "solid-js/web";
import { Layout } from "./layout.tsx";
import "./styles/index.css";
+
import { CarView } from "./views/car.tsx";
import { CollectionView } from "./views/collection.tsx";
import { Home } from "./views/home.tsx";
import { LabelView } from "./views/labels.tsx";
···
<Route path="/" component={Home} />
<Route path={["/jetstream", "/firehose"]} component={StreamView} />
<Route path="/labels" component={LabelView} />
+
<Route path="/car" component={CarView} />
<Route path="/settings" component={Settings} />
<Route path="/:pds" component={PdsView} />
<Route path="/:pds/:repo" component={RepoView} />
+1
src/layout.tsx
···
<NavMenu href="/jetstream" label="Jetstream" icon="lucide--radio-tower" />
<NavMenu href="/firehose" label="Firehose" icon="lucide--antenna" />
<NavMenu href="/labels" label="Labels" icon="lucide--tag" />
+
<NavMenu href="/car" label="CAR explorer" icon="lucide--folder-archive" />
<NavMenu href="/settings" label="Settings" icon="lucide--settings" />
<MenuSeparator />
<NavMenu
+2 -4
src/views/car.tsx
···
class="truncate font-medium"
classList={{
"text-neutral-700 dark:text-neutral-300": hasSingleEntry,
-
"text-blue-500 dark:text-blue-400": !hasSingleEntry,
+
"text-blue-400": !hasSingleEntry,
}}
>
{entry.name}
···
<Show when={hasSingleEntry}>
<span class="iconify lucide--chevron-right shrink-0 text-xs text-neutral-500" />
-
<span class="truncate font-medium text-blue-500 dark:text-blue-400">
-
{entry.entries[0].key}
-
</span>
+
<span class="truncate font-medium text-blue-400">{entry.entries[0].key}</span>
</Show>
<Show when={!hasSingleEntry}>
+15 -1
src/components/notification.tsx
···
progress?: number;
total?: number;
type?: "info" | "success" | "error";
+
onCancel?: () => void;
};
const [notifications, setNotifications] = createStore<Notification[]>([]);
···
"animate-[slideIn_0.25s_ease-in]": !removingIds().has(notification.id),
"animate-[slideOut_0.25s_ease-in]": removingIds().has(notification.id),
}}
-
onClick={() => removeNotification(notification.id)}
+
onClick={() =>
+
notification.progress === undefined && removeNotification(notification.id)
+
}
>
<div class="flex items-center gap-2 text-sm">
<Show when={notification.progress !== undefined}>
···
{notification.progress}%
</div>
</Show>
+
<Show when={notification.onCancel}>
+
<button
+
class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 mt-1 rounded border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"
+
onClick={(e) => {
+
e.stopPropagation();
+
notification.onCancel?.();
+
}}
+
>
+
Cancel
+
</button>
+
</Show>
</div>
</Show>
</div>
+24 -11
src/views/repo.tsx
···
const downloadRepo = async () => {
let notificationId: string | null = null;
+
const abortController = new AbortController();
try {
setDownloading(true);
···
progress: 0,
total: 0,
type: "info",
+
onCancel: () => {
+
abortController.abort();
+
if (notificationId) {
+
removeNotification(notificationId);
+
}
+
},
});
-
const response = await fetch(`${pds}/xrpc/com.atproto.sync.getRepo?did=${did}`);
+
const response = await fetch(`${pds}/xrpc/com.atproto.sync.getRepo?did=${did}`, {
+
signal: abortController.signal,
+
});
if (!response.ok) {
throw new Error(`HTTP error status: ${response.status}`);
}
···
message: "Repository downloaded successfully",
type: "success",
progress: undefined,
+
onCancel: undefined,
});
setTimeout(() => {
if (notificationId) removeNotification(notificationId);
}, 3000);
} catch (error) {
-
console.error("Download failed:", error);
-
if (notificationId) {
-
updateNotification(notificationId, {
-
message: "Download failed",
-
type: "error",
-
progress: undefined,
-
});
-
setTimeout(() => {
-
if (notificationId) removeNotification(notificationId);
-
}, 5000);
+
if (!(error instanceof Error && error.name === "AbortError")) {
+
console.error("Download failed:", error);
+
if (notificationId) {
+
updateNotification(notificationId, {
+
message: "Download failed",
+
type: "error",
+
progress: undefined,
+
onCancel: undefined,
+
});
+
setTimeout(() => {
+
if (notificationId) removeNotification(notificationId);
+
}, 5000);
+
}
}
}
setDownloading(false);