atmosphere explorer pds.ls
tool typescript atproto

Compare changes

Choose any two refs to compare.

+4732 -938
+1
src/components/create.tsx
··· 58 onMount(() => { 59 const keyEvent = (ev: KeyboardEvent) => { 60 if (ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement) return; 61 62 const key = props.create ? "n" : "e"; 63 if (ev.key === key) {
··· 58 onMount(() => { 59 const keyEvent = (ev: KeyboardEvent) => { 60 if (ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement) return; 61 + if ((ev.target as HTMLElement).closest("[data-modal]")) return; 62 63 const key = props.create ? "n" : "e"; 64 if (ev.key === key) {
+104
src/components/create/file-upload.tsx
···
··· 1 + import { Client } from "@atcute/client"; 2 + import { remove } from "@mary/exif-rm"; 3 + import { createSignal, onCleanup, Show } from "solid-js"; 4 + import { agent } from "../../auth/state"; 5 + import { formatFileSize } from "../../utils/format"; 6 + import { Button } from "../button.jsx"; 7 + import { TextInput } from "../text-input.jsx"; 8 + import { editorInstance } from "./state"; 9 + 10 + export const FileUpload = (props: { 11 + file: File; 12 + blobInput: HTMLInputElement; 13 + onClose: () => void; 14 + }) => { 15 + const [uploading, setUploading] = createSignal(false); 16 + const [error, setError] = createSignal(""); 17 + 18 + onCleanup(() => (props.blobInput.value = "")); 19 + 20 + const uploadBlob = async () => { 21 + let blob: Blob; 22 + 23 + const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value; 24 + (document.getElementById("mimetype") as HTMLInputElement).value = ""; 25 + if (mimetype) blob = new Blob([props.file], { type: mimetype }); 26 + else blob = props.file; 27 + 28 + if ((document.getElementById("exif-rm") as HTMLInputElement).checked) { 29 + const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer())); 30 + if (exifRemoved !== null) blob = new Blob([exifRemoved as BlobPart], { type: blob.type }); 31 + } 32 + 33 + const rpc = new Client({ handler: agent()! }); 34 + setUploading(true); 35 + const res = await rpc.post("com.atproto.repo.uploadBlob", { 36 + input: blob, 37 + }); 38 + setUploading(false); 39 + if (!res.ok) { 40 + setError(res.data.error); 41 + return; 42 + } 43 + editorInstance.view.dispatch({ 44 + changes: { 45 + from: editorInstance.view.state.selection.main.head, 46 + insert: JSON.stringify(res.data.blob, null, 2), 47 + }, 48 + }); 49 + props.onClose(); 50 + }; 51 + 52 + return ( 53 + <> 54 + <h2 class="mb-2 font-semibold">Upload blob</h2> 55 + <div class="flex flex-col gap-2 text-sm"> 56 + <div class="flex flex-col gap-1"> 57 + <p class="flex gap-1"> 58 + <span class="truncate">{props.file.name}</span> 59 + <span class="shrink-0 text-neutral-600 dark:text-neutral-400"> 60 + ({formatFileSize(props.file.size)}) 61 + </span> 62 + </p> 63 + </div> 64 + <div class="flex items-center gap-x-2"> 65 + <label for="mimetype" class="shrink-0 select-none"> 66 + MIME type 67 + </label> 68 + <TextInput id="mimetype" placeholder={props.file.type} /> 69 + </div> 70 + <div class="flex items-center gap-1"> 71 + <input id="exif-rm" type="checkbox" checked /> 72 + <label for="exif-rm" class="select-none"> 73 + Remove EXIF data 74 + </label> 75 + </div> 76 + <p class="text-xs text-neutral-600 dark:text-neutral-400"> 77 + Metadata will be pasted after the cursor 78 + </p> 79 + <Show when={error()}> 80 + <span class="text-red-500 dark:text-red-400">Error: {error()}</span> 81 + </Show> 82 + <div class="flex justify-between gap-2"> 83 + <Button onClick={props.onClose}>Cancel</Button> 84 + <Show when={uploading()}> 85 + <div class="flex items-center gap-1"> 86 + <span class="iconify lucide--loader-circle animate-spin"></span> 87 + <span>Uploading</span> 88 + </div> 89 + </Show> 90 + <Show when={!uploading()}> 91 + <Button 92 + onClick={uploadBlob} 93 + classList={{ 94 + "bg-blue-500! text-white! border-none! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400!": true, 95 + }} 96 + > 97 + Upload 98 + </Button> 99 + </Show> 100 + </div> 101 + </div> 102 + </> 103 + ); 104 + };
+89
src/components/create/handle-input.tsx
···
··· 1 + import { Handle } from "@atcute/lexicons"; 2 + import { createSignal, Show } from "solid-js"; 3 + import { resolveHandle } from "../../utils/api"; 4 + import { Button } from "../button.jsx"; 5 + import { TextInput } from "../text-input.jsx"; 6 + import { editorInstance } from "./state"; 7 + 8 + export const HandleInput = (props: { onClose: () => void }) => { 9 + const [resolving, setResolving] = createSignal(false); 10 + const [error, setError] = createSignal(""); 11 + let handleFormRef!: HTMLFormElement; 12 + 13 + const resolveDid = async (e: SubmitEvent) => { 14 + e.preventDefault(); 15 + const formData = new FormData(handleFormRef); 16 + const handleValue = formData.get("handle")?.toString().trim(); 17 + 18 + if (!handleValue) { 19 + setError("Please enter a handle"); 20 + return; 21 + } 22 + 23 + setResolving(true); 24 + setError(""); 25 + try { 26 + const did = await resolveHandle(handleValue as Handle); 27 + editorInstance.view.dispatch({ 28 + changes: { 29 + from: editorInstance.view.state.selection.main.head, 30 + insert: `"${did}"`, 31 + }, 32 + }); 33 + props.onClose(); 34 + handleFormRef.reset(); 35 + } catch (err: any) { 36 + setError(err.message || "Failed to resolve handle"); 37 + } finally { 38 + setResolving(false); 39 + } 40 + }; 41 + 42 + return ( 43 + <> 44 + <h2 class="mb-2 font-semibold">Insert DID from handle</h2> 45 + <form ref={handleFormRef} onSubmit={resolveDid} class="flex flex-col gap-2 text-sm"> 46 + <div class="flex flex-col gap-1"> 47 + <label for="handle-input" class="select-none"> 48 + Handle 49 + </label> 50 + <TextInput id="handle-input" name="handle" placeholder="user.bsky.social" /> 51 + </div> 52 + <p class="text-xs text-neutral-600 dark:text-neutral-400"> 53 + DID will be pasted after the cursor 54 + </p> 55 + <Show when={error()}> 56 + <span class="text-red-500 dark:text-red-400">Error: {error()}</span> 57 + </Show> 58 + <div class="flex justify-between gap-2"> 59 + <Button 60 + type="button" 61 + onClick={() => { 62 + props.onClose(); 63 + handleFormRef.reset(); 64 + setError(""); 65 + }} 66 + > 67 + Cancel 68 + </Button> 69 + <Show when={resolving()}> 70 + <div class="flex items-center gap-1"> 71 + <span class="iconify lucide--loader-circle animate-spin"></span> 72 + <span>Resolving</span> 73 + </div> 74 + </Show> 75 + <Show when={!resolving()}> 76 + <Button 77 + type="submit" 78 + classList={{ 79 + "bg-blue-500! text-white! border-none! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400!": true, 80 + }} 81 + > 82 + Insert 83 + </Button> 84 + </Show> 85 + </div> 86 + </form> 87 + </> 88 + ); 89 + };
+12
src/components/create/menu-item.tsx
···
··· 1 + export const MenuItem = (props: { icon: string; label: string; onClick: () => void }) => { 2 + return ( 3 + <button 4 + type="button" 5 + 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" 6 + onClick={props.onClick} 7 + > 8 + <span class={`iconify ${props.icon}`}></span> 9 + <span>{props.label}</span> 10 + </button> 11 + ); 12 + };
+4
src/components/create/state.ts
···
··· 1 + import { createSignal } from "solid-js"; 2 + 3 + export const editorInstance = { view: null as any }; 4 + export const [placeholder, setPlaceholder] = createSignal<any>();
+42 -1
src/components/editor.tsx
··· 7 import { basicLight } from "@fsegurai/codemirror-theme-basic-light"; 8 import { basicSetup, EditorView } from "codemirror"; 9 import { onCleanup, onMount } from "solid-js"; 10 - import { editorInstance } from "./create"; 11 12 const Editor = (props: { content: string }) => { 13 let editorDiv!: HTMLDivElement;
··· 7 import { basicLight } from "@fsegurai/codemirror-theme-basic-light"; 8 import { basicSetup, EditorView } from "codemirror"; 9 import { onCleanup, onMount } from "solid-js"; 10 + import { editorInstance } from "./create/state"; 11 12 const Editor = (props: { content: string }) => { 13 let editorDiv!: HTMLDivElement; 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + 22 + 23 + 24 + 25 + 26 + 27 + 28 + 29 + 30 + 31 + 32 + 33 + 34 + 35 + 36 + 37 + 38 + 39 + 40 + 41 + 42 + 43 + 44 + 45 + 46 + 47 + 48 + keymap.of([indentWithTab]), 49 + linter(jsonParseLinter()), 50 + themeColor.of(document.documentElement.classList.contains("dark") ? basicDark : basicLight), 51 + EditorView.lineWrapping, 52 + ], 53 + }); 54 + editorInstance.view = view;
+97
src/components/create/confirm-submit.tsx
···
··· 1 + import { createSignal, Show } from "solid-js"; 2 + import { hasUserScope } from "../../auth/scope-utils"; 3 + import { Button } from "../button.jsx"; 4 + 5 + export const ConfirmSubmit = (props: { 6 + isCreate: boolean; 7 + onConfirm: (validate: boolean | undefined, recreate: boolean) => void; 8 + onClose: () => void; 9 + }) => { 10 + const [validate, setValidate] = createSignal<boolean | undefined>(undefined); 11 + const [recreate, setRecreate] = createSignal(false); 12 + 13 + const getValidateLabel = () => { 14 + return ( 15 + validate() === true ? "True" 16 + : validate() === false ? "False" 17 + : "Unset" 18 + ); 19 + }; 20 + 21 + const cycleValidate = () => { 22 + setValidate( 23 + validate() === undefined ? true 24 + : validate() === true ? false 25 + : undefined, 26 + ); 27 + }; 28 + 29 + return ( 30 + <> 31 + <div class="flex flex-col gap-3 text-sm"> 32 + <h2 class="font-semibold">{props.isCreate ? "Create" : "Edit"} record</h2> 33 + <div class="flex flex-col gap-1.5"> 34 + <div class="flex items-center gap-2"> 35 + <button 36 + type="button" 37 + 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" 38 + onClick={cycleValidate} 39 + > 40 + <span 41 + classList={{ 42 + iconify: true, 43 + "lucide--square-check text-green-500 dark:text-green-400": validate() === true, 44 + "lucide--square-x text-red-500 dark:text-red-400": validate() === false, 45 + "lucide--square text-neutral-500 dark:text-neutral-400": validate() === undefined, 46 + }} 47 + ></span> 48 + <span>Validate: {getValidateLabel()}</span> 49 + </button> 50 + </div> 51 + <p class="text-xs text-neutral-600 dark:text-neutral-400"> 52 + Set to 'false' to skip lexicon schema validation by the PDS, 'true' to require it, or 53 + leave unset to validate only for known lexicons. 54 + </p> 55 + </div> 56 + <Show when={!props.isCreate}> 57 + <div class="flex flex-col gap-1.5"> 58 + <div class="flex items-center gap-2"> 59 + <button 60 + type="button" 61 + class={ 62 + hasUserScope("create") ? 63 + "-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" 64 + : "-ml-2 flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs opacity-40" 65 + } 66 + onClick={() => hasUserScope("create") && setRecreate(!recreate())} 67 + > 68 + <span 69 + classList={{ 70 + iconify: true, 71 + "lucide--square-check text-green-500 dark:text-green-400": recreate(), 72 + "lucide--square text-neutral-500 dark:text-neutral-400": !recreate(), 73 + }} 74 + ></span> 75 + <span>Recreate{hasUserScope("create") ? "" : " (create permission needed)"}</span> 76 + </button> 77 + </div> 78 + <p class="text-xs text-neutral-600 dark:text-neutral-400"> 79 + Delete the existing record and create a new one with the same record key. 80 + </p> 81 + </div> 82 + </Show> 83 + <div class="flex justify-between gap-2"> 84 + <Button onClick={props.onClose}>Cancel</Button> 85 + <Button 86 + onClick={() => props.onConfirm(validate(), recreate())} 87 + classList={{ 88 + "bg-blue-500! text-white! border-none! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400!": true, 89 + }} 90 + > 91 + {props.isCreate ? "Create" : "Edit"} 92 + </Button> 93 + </div> 94 + </div> 95 + </> 96 + ); 97 + };
+230 -33
src/views/home.tsx
··· 1 - export const Home = () => { 2 - return ( 3 - <div class="flex w-full flex-col gap-3 wrap-break-word"> 4 - <div class="flex flex-col gap-0.5"> 5 - <div> 6 - <span class="text-xl font-semibold">AT Protocol Explorer</span> 7 - </div> 8 - 9 - 10 - 11 - 12 13 14 15 - . 16 - </span> 17 </div> 18 - <div class="flex items-center gap-1"> 19 - <div class="iconify lucide--user-round" /> 20 - <span>Login to manage records in your repository.</span> 21 </div> 22 - <div class="flex items-center gap-1"> 23 - <div class="iconify lucide--radio-tower" /> 24 - <span>Jetstream and firehose streaming.</span> 25 </div> 26 - <div class="flex items-center gap-1"> 27 - <div class="iconify lucide--link" /> 28 - <span> 29 - 30 - 31 - 32 - 33 - 34 - 35 36 37 - . 38 - </span> 39 </div> 40 - <div class="flex items-center gap-1"> 41 - <div class="iconify lucide--tag" /> 42 - <span>Query labels from moderation services.</span>
··· 1 + import { A } from "@solidjs/router"; 2 + import { For, JSX } from "solid-js"; 3 + import { setOpenManager, setShowAddAccount } from "../auth/state"; 4 + import { Button } from "../components/button"; 5 + import { SearchButton } from "../components/search"; 6 + 7 + type ProfileData = { 8 + did: string; 9 + handle: string; 10 + }; 11 12 + export const Home = () => { 13 + const FooterLink = (props: { 14 + href: string; 15 + color: string; 16 + darkColor?: string; 17 + children: JSX.Element; 18 + }) => ( 19 + <a 20 + href={props.href} 21 + class={`relative flex items-center gap-1.5 after:absolute after:bottom-0 after:left-0 after:h-px after:w-0 after:bg-current ${props.color} after:transition-[width] after:duration-300 after:ease-out hover:after:w-full ${props.darkColor ?? ""}`} 22 + target="_blank" 23 + > 24 + {props.children} 25 + </a> 26 + ); 27 + 28 + const allExampleProfiles: ProfileData[] = [ 29 + { did: "did:plc:7vimlesenouvuaqvle42yhvo", handle: "juli.ee" }, 30 + { did: "did:plc:oisofpd7lj26yvgiivf3lxsi", handle: "hailey.at" }, 31 + { did: "did:plc:vwzwgnygau7ed7b7wt5ux7y2", handle: "retr0.id" }, 32 + { did: "did:plc:vc7f4oafdgxsihk4cry2xpze", handle: "jcsalterego.bsky.social" }, 33 + { did: "did:plc:uu5axsmbm2or2dngy4gwchec", handle: "futur.blue" }, 34 + { did: "did:plc:ia76kvnndjutgedggx2ibrem", handle: "mary.my.id" }, 35 + { did: "did:plc:hdhoaan3xa3jiuq4fg4mefid", handle: "bad-example.com" }, 36 + { did: "did:plc:q6gjnaw2blty4crticxkmujt", handle: "jaz.sh" }, 37 + { did: "did:plc:jrtgsidnmxaen4offglr5lsh", handle: "quilling.dev" }, 38 + { did: "did:plc:3c6vkaq7xf5kz3va3muptjh5", handle: "aylac.top" }, 39 + { did: "did:plc:gwd5r7dbg3zv6dhv75hboa3f", handle: "mofu.run" }, 40 + { did: "did:plc:tzrpqyerzt37pyj54hh52xrz", handle: "rainy.pet" }, 41 + { did: "did:plc:qx7in36j344d7qqpebfiqtew", handle: "futanari.observer" }, 42 + { did: "did:plc:ucaezectmpny7l42baeyooxi", handle: "sapphic.moe" }, 43 + { did: "did:plc:6v6jqsy7swpzuu53rmzaybjy", handle: "computer.fish" }, 44 + { did: "did:plc:w4nvvt6feq2l3qgnwl6a7g7d", handle: "emilia.wtf" }, 45 + { did: "did:plc:xwhsmuozq3mlsp56dyd7copv", handle: "paizuri.moe" }, 46 + { did: "did:plc:aokggmp5jzj4nc5jifhiplqc", handle: "dreary.blacksky.app" }, 47 + { did: "did:plc:k644h4rq5bjfzcetgsa6tuby", handle: "natalie.sh" }, 48 + { did: "did:plc:ttdrpj45ibqunmfhdsb4zdwq", handle: "nekomimi.pet" }, 49 + { did: "did:plc:fz2tul67ziakfukcwa3vdd5d", handle: "nullekko.moe" }, 50 + { did: "did:plc:qxichs7jsycphrsmbujwqbfb", handle: "isabelroses.com" }, 51 + { did: "did:plc:fnvdhaoe7b5abgrtvzf4ttl5", handle: "isuggest.selfce.st" }, 52 + { did: "did:plc:p5yjdr64h7mk5l3kh6oszryk", handle: "blooym.dev" }, 53 + { did: "did:plc:hvakvedv6byxhufjl23mfmsd", handle: "number-one-warned.rat.mom" }, 54 + { did: "did:plc:6if5m2yo6kroprmmency3gt5", handle: "olaren.dev" }, 55 + { did: "did:plc:w7adfxpixpi77e424cjjxnxy", handle: "anyaustin.bsky.social" }, 56 + { did: "did:plc:h6as5sk7tfqvvnqvfrlnnwqn", handle: "cwonus.org" }, 57 + { did: "did:plc:mo7bk6gblylupvhetkqmndrv", handle: "claire.on-her.computer" }, 58 + { did: "did:plc:73gqgbnvpx5syidcponjrics", handle: "coil-habdle.ebil.club" }, 59 + { did: "did:plc:gy5roooborfiyvl2xadsam3e", handle: "slug.moe" }, 60 + { did: "did:plc:dadnngq7hpnuglhxm556wgzi", handle: "drunk.moe" }, 61 + { did: "did:plc:ra3gxl2udc22odfbvcfslcn3", handle: "notnite.com" }, 62 + { did: "did:plc:h5wsnqetncv6lu2weom35lg2", handle: "nel.pet" }, 63 + { did: "did:plc:irs2tcoeuvuwj3m4yampbuco", handle: "shi.gg" }, 64 + { did: "did:plc:vafqb3yhndyawabm2t2zhw5z", handle: "neko.moe.observer" }, 65 + ]; 66 67 + const profiles = [...allExampleProfiles].sort(() => Math.random() - 0.5).slice(0, 3); 68 69 + return ( 70 + <div class="flex w-full flex-col gap-5 px-2 wrap-break-word"> 71 + {/* Welcome Section */} 72 + <div class="flex flex-col gap-4"> 73 + <div class="flex flex-col gap-1"> 74 + <h1 class="text-lg font-medium">Atmosphere Explorer</h1> 75 + <div class="text-sm text-neutral-600 dark:text-neutral-300"> 76 + <p> 77 + Browse the public data on the{" "} 78 + <a 79 + href="https://atproto.com" 80 + target="_blank" 81 + class="underline decoration-neutral-400 transition-colors hover:text-blue-500 hover:decoration-blue-500 dark:decoration-neutral-500 dark:hover:text-blue-400" 82 + > 83 + AT Protocol 84 + </a> 85 + </p> 86 + </div> 87 </div> 88 + 89 + {/* Example Repos */} 90 + <section class="mb-1 flex flex-col gap-3"> 91 + <div class="flex justify-between"> 92 + <For each={profiles}> 93 + {(profile) => ( 94 + <A 95 + href={`/at://${profile.did}`} 96 + class="group flex min-w-0 basis-1/3 flex-col items-center gap-1.5 transition-transform hover:scale-105 active:scale-105" 97 + > 98 + <img 99 + src={`/avatar/${profile.handle}.jpg`} 100 + alt={`Bluesky profile picture of ${profile.handle}`} 101 + class="size-16 rounded-full ring-2 ring-transparent transition-all group-hover:ring-blue-500 active:ring-blue-500 dark:group-hover:ring-blue-400 dark:active:ring-blue-400" 102 + classList={{ 103 + "animate-[spin_5s_linear_infinite] [animation-play-state:paused] group-hover:[animation-play-state:running]": 104 + profile.handle === "coil-habdle.ebil.club", 105 + }} 106 + /> 107 + <span class="w-full truncate text-center text-xs text-neutral-600 dark:text-neutral-300"> 108 + @{profile.handle} 109 + </span> 110 + </A> 111 + )} 112 + </For> 113 + </div> 114 + </section> 115 + <div class="flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400"> 116 + <SearchButton /> 117 + <span>to find any account</span> 118 </div> 119 + <div class="flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400"> 120 + <Button 121 + onClick={() => { 122 + setOpenManager(true); 123 + setShowAddAccount(true); 124 + }} 125 + > 126 + <span class="iconify lucide--user-round"></span> 127 + Sign in 128 + </Button> 129 + <span>to manage records</span> 130 </div> 131 + </div> 132 133 + <div class="flex flex-col gap-4 text-sm"> 134 + <div class="flex flex-col gap-2"> 135 + <A 136 + href="/jetstream" 137 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 138 + > 139 + <div class="iconify lucide--radio-tower" /> 140 + <span class="underline decoration-transparent group-hover:decoration-current"> 141 + Jetstream 142 + </span> 143 + <div /> 144 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 145 + Event stream with filtering 146 + </span> 147 + </A> 148 + <A 149 + href="/firehose" 150 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 151 + > 152 + <div class="iconify lucide--rss" /> 153 + <span class="underline decoration-transparent group-hover:decoration-current"> 154 + Firehose 155 + </span> 156 + <div /> 157 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 158 + Raw relay event stream 159 + </span> 160 + </A> 161 + <A 162 + href="/spacedust" 163 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 164 + > 165 + <div class="iconify lucide--orbit" /> 166 + <span class="underline decoration-transparent group-hover:decoration-current"> 167 + Spacedust 168 + </span> 169 + <div /> 170 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 171 + Interaction links stream 172 + </span> 173 + </A> 174 + </div> 175 176 + <div class="flex flex-col gap-2"> 177 + <A 178 + href="/labels" 179 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 180 + > 181 + <div class="iconify lucide--tag" /> 182 + <span class="underline decoration-transparent group-hover:decoration-current"> 183 + Labels 184 + </span> 185 + <div /> 186 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 187 + Query labeler services 188 + </span> 189 + </A> 190 + <A 191 + href="/car" 192 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 193 + > 194 + <div class="iconify lucide--folder-archive" /> 195 + <span class="underline decoration-transparent group-hover:decoration-current"> 196 + Archive 197 + </span> 198 + <div /> 199 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 200 + Explore and unpack CAR files 201 + </span> 202 + </A> 203 </div> 204 + </div> 205 + 206 + <div class="flex justify-center gap-1.5 text-sm text-neutral-600 sm:gap-2 dark:text-neutral-300"> 207 + <FooterLink 208 + href="https://juli.ee" 209 + color="after:text-rose-400" 210 + darkColor="dark:after:text-rose-300" 211 + > 212 + <span class="iconify lucide--terminal text-rose-400 dark:text-rose-300"></span> 213 + <span class="font-pecita">juliet</span> 214 + </FooterLink> 215 + {/* โ€ข */} 216 + {/* <FooterLink href="https://raycast.com/" color="after:text-[#FF6363]"> */} 217 + {/* <span class="iconify-color i-raycast-light block dark:hidden"></span> */} 218 + {/* <span class="iconify-color i-raycast-dark hidden dark:block"></span> */} 219 + {/* Raycast */} 220 + {/* </FooterLink> */}โ€ข 221 + <FooterLink 222 + href="https://bsky.app/profile/did:plc:6q5daed5gutiyerimlrnojnz" 223 + color="after:text-[#0085ff]" 224 + > 225 + <span class="simple-icons--bluesky iconify text-[#0085ff]"></span> 226 + Bluesky 227 + </FooterLink> 228 + โ€ข 229 + <FooterLink 230 + href="https://tangled.org/did:plc:6q5daed5gutiyerimlrnojnz/pdsls/" 231 + color="after:text-black" 232 + darkColor="dark:after:text-white" 233 + > 234 + <span class="iconify i-tangled text-black dark:text-white"></span> 235 + Source 236 + </FooterLink> 237 + </div> 238 + </div> 239 + );
+2 -2
src/components/sticky.tsx
··· 29 /> 30 31 <div 32 - class="sticky top-2 z-10 flex w-full flex-col items-center justify-center gap-2 rounded-lg p-3 transition-colors" 33 classList={{ 34 - "bg-neutral-50 dark:bg-dark-300 border-[0.5px] border-neutral-300 dark:border-neutral-700 shadow-md": 35 filterStuck(), 36 "bg-transparent border-transparent shadow-none": !filterStuck(), 37 }}
··· 29 /> 30 31 <div 32 + 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" 33 classList={{ 34 + "bg-neutral-50 dark:bg-dark-300 border-neutral-300 dark:border-neutral-700 shadow-md": 35 filterStuck(), 36 "bg-transparent border-transparent shadow-none": !filterStuck(), 37 }}
+33 -15
src/views/blob.tsx
··· 27 28 29 30 - 31 - 32 - 33 - 34 - 35 - 36 - 37 - 38 - 39 - 40 - 41 - 42 - 43 - 44 </For> 45 </div> 46 </Show> 47 - <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 py-2"> 48 <div class="flex flex-col items-center gap-1 pb-2"> 49 <p> 50 {blobs()?.length} blob{(blobs()?.length ?? 0 > 1) ? "s" : ""}
··· 27 28 29 30 + return ( 31 + <div class="flex flex-col items-center gap-2"> 32 + <Show when={blobs() || response()}> 33 + <div class="flex w-full flex-col gap-0.5 pb-20 font-mono text-xs sm:text-sm"> 34 + <For each={blobs()}> 35 + {(cid) => ( 36 + <a 37 + href={`${props.pds}/xrpc/com.atproto.sync.getBlob?did=${props.repo}&cid=${cid}`} 38 + target="_blank" 39 + class="truncate rounded px-0.5 text-left text-blue-500 hover:bg-neutral-200 active:bg-neutral-300 dark:text-blue-400 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 40 + dir="rtl" 41 + > 42 + {cid} 43 + </a> 44 + )} 45 </For> 46 </div> 47 </Show> 48 + <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 pt-2 pb-4"> 49 <div class="flex flex-col items-center gap-1 pb-2"> 50 <p> 51 {blobs()?.length} blob{(blobs()?.length ?? 0 > 1) ? "s" : ""} 52 + </p> 53 + <Show when={cursor()}> 54 + <Button 55 + onClick={() => refetch()} 56 + disabled={response.loading} 57 + classList={{ "w-20 justify-center": true }} 58 + > 59 + <Show 60 + when={!response.loading} 61 + fallback={<span class="iconify lucide--loader-circle animate-spin text-base" />} 62 + > 63 + Load more 64 + </Show> 65 + </Button> 66 + </Show> 67 + </div> 68 + </div>
+142 -173
src/components/backlinks.tsx
··· 1 import * as TID from "@atcute/tid"; 2 import { createResource, createSignal, For, onMount, Show } from "solid-js"; 3 - import { 4 - getAllBacklinks, 5 - getDidBacklinks, 6 - getRecordBacklinks, 7 - LinksWithDids, 8 - LinksWithRecords, 9 - } from "../utils/api.js"; 10 import { localDateFromTimestamp } from "../utils/date.js"; 11 import { Button } from "./button.jsx"; 12 13 - type Backlink = { 14 path: string; 15 counts: { distinct_dids: number; records: number }; 16 }; 17 18 - const linksBySource = (links: Record<string, any>) => { 19 - let out: Record<string, Backlink[]> = {}; 20 Object.keys(links) 21 .toSorted() 22 .forEach((collection) => { ··· 24 Object.keys(paths) 25 .toSorted() 26 .forEach((path) => { 27 - if (paths[path].records === 0) return; 28 - if (out[collection]) out[collection].push({ path, counts: paths[path] }); 29 - else out[collection] = [{ path, counts: paths[path] }]; 30 }); 31 }); 32 - return out; 33 }; 34 35 - const Backlinks = (props: { target: string }) => { 36 - const fetchBacklinks = async () => { 37 - const res = await getAllBacklinks(props.target); 38 - return linksBySource(res.links); 39 - }; 40 - 41 - const [response] = createResource(fetchBacklinks); 42 43 - const [show, setShow] = createSignal<{ 44 - collection: string; 45 - path: string; 46 - showDids: boolean; 47 - } | null>(); 48 49 return ( 50 - <div class="flex w-full flex-col gap-1 text-sm wrap-anywhere"> 51 - <Show 52 - when={response() && Object.keys(response()!).length} 53 - fallback={<p>No backlinks found.</p>} 54 - > 55 - <For each={Object.keys(response()!)}> 56 - {(collection) => ( 57 - <div> 58 - <div class="flex items-center gap-1"> 59 - <span class="iconify lucide--book-text shrink-0"></span> 60 - {collection} 61 - </div> 62 - <For each={response()![collection]}> 63 - {({ path, counts }) => ( 64 - <div class="ml-4.5"> 65 - <div class="flex items-center gap-1"> 66 - <span class="iconify lucide--route shrink-0"></span> 67 - {path.slice(1)} 68 - </div> 69 - <div class="ml-4.5"> 70 - <p> 71 - <button 72 - class="text-blue-400 hover:underline active:underline" 73 - onclick={() => 74 - ( 75 - show()?.collection === collection && 76 - show()?.path === path && 77 - !show()?.showDids 78 - ) ? 79 - setShow(null) 80 - : setShow({ collection, path, showDids: false }) 81 - } 82 - > 83 - {counts.records} record{counts.records < 2 ? "" : "s"} 84 - </button> 85 - {" from "} 86 - <button 87 - class="text-blue-400 hover:underline active:underline" 88 - onclick={() => 89 - ( 90 - show()?.collection === collection && 91 - show()?.path === path && 92 - show()?.showDids 93 - ) ? 94 - setShow(null) 95 - : setShow({ collection, path, showDids: true }) 96 - } 97 - > 98 - {counts.distinct_dids} DID 99 - {counts.distinct_dids < 2 ? "" : "s"} 100 - </button> 101 - </p> 102 - <Show when={show()?.collection === collection && show()?.path === path}> 103 - <Show when={show()?.showDids}> 104 - <p class="w-full font-semibold">Distinct identities</p> 105 - <BacklinkItems 106 - target={props.target} 107 - collection={collection} 108 - path={path} 109 - dids={true} 110 - /> 111 - </Show> 112 - <Show when={!show()?.showDids}> 113 - <p class="w-full font-semibold">Records</p> 114 - <BacklinkItems 115 - target={props.target} 116 - collection={collection} 117 - path={path} 118 - dids={false} 119 - /> 120 - </Show> 121 - </Show> 122 - </div> 123 - </div> 124 - )} 125 - </For> 126 </div> 127 - )} 128 - </For> 129 </Show> 130 - </div> 131 ); 132 }; 133 134 - // switching on !!did everywhere is pretty annoying, this could probably be two components 135 - // but i don't want to duplicate or think about how to extract the paging logic 136 - const BacklinkItems = ({ 137 - target, 138 - collection, 139 - path, 140 - dids, 141 - cursor, 142 - }: { 143 - target: string; 144 - collection: string; 145 - path: string; 146 - dids: boolean; 147 - cursor?: string; 148 - }) => { 149 - const [links, setLinks] = createSignal<LinksWithDids | LinksWithRecords>(); 150 - const [more, setMore] = createSignal<boolean>(false); 151 - 152 - onMount(async () => { 153 - const links = await (dids ? getDidBacklinks : getRecordBacklinks)( 154 - target, 155 - collection, 156 - path, 157 - cursor, 158 - ); 159 - setLinks(links); 160 }); 161 162 - // TODO: could pass the `total` into this component, which can be checked against each call to this endpoint to find if it's stale. 163 - // also hmm 'total' is misleading/wrong on that api 164 - 165 return ( 166 - <Show when={links()} fallback={<p>Loading&hellip;</p>}> 167 - <Show when={dids}> 168 - <For each={(links() as LinksWithDids).linking_dids}> 169 - {(did) => ( 170 - <a 171 - href={`/at://${did}`} 172 - class="relative flex w-full font-mono text-blue-400 hover:underline active:underline" 173 - > 174 - {did} 175 - </a> 176 - )} 177 - </For> 178 - </Show> 179 - <Show when={!dids}> 180 - <For each={(links() as LinksWithRecords).linking_records}> 181 - {({ did, collection, rkey }) => ( 182 - <p class="relative flex w-full items-center gap-1 font-mono"> 183 - <a 184 - href={`/at://${did}/${collection}/${rkey}`} 185 - class="text-blue-400 hover:underline active:underline" 186 - > 187 - {rkey} 188 - </a> 189 - <span class="text-xs text-neutral-500 dark:text-neutral-400"> 190 - {TID.validate(rkey) ? 191 - localDateFromTimestamp(TID.parse(rkey).timestamp / 1000) 192 - : undefined} 193 - </span> 194 - </p> 195 )} 196 </For> 197 </Show> 198 - <Show when={links()?.cursor}> 199 - <Show when={more()} fallback={<Button onClick={() => setMore(true)}>Load More</Button>}> 200 - <BacklinkItems 201 - target={target} 202 - collection={collection} 203 - path={path} 204 - dids={dids} 205 - cursor={links()!.cursor} 206 /> 207 - </Show> 208 </Show> 209 - </Show> 210 ); 211 }; 212
··· 1 import * as TID from "@atcute/tid"; 2 import { createResource, createSignal, For, onMount, Show } from "solid-js"; 3 + import { getAllBacklinks, getRecordBacklinks, LinksWithRecords } from "../utils/api.js"; 4 import { localDateFromTimestamp } from "../utils/date.js"; 5 import { Button } from "./button.jsx"; 6 + import { Favicon } from "./favicon.jsx"; 7 + import DidHoverCard from "./hover-card/did.jsx"; 8 + import RecordHoverCard from "./hover-card/record.jsx"; 9 10 + type BacklinksProps = { 11 + target: string; 12 + collection: string; 13 + path: string; 14 + }; 15 + 16 + type BacklinkEntry = { 17 + collection: string; 18 path: string; 19 counts: { distinct_dids: number; records: number }; 20 }; 21 22 + const flattenLinks = (links: Record<string, any>): BacklinkEntry[] => { 23 + const entries: BacklinkEntry[] = []; 24 Object.keys(links) 25 .toSorted() 26 .forEach((collection) => { ··· 28 Object.keys(paths) 29 .toSorted() 30 .forEach((path) => { 31 + if (paths[path].records > 0) { 32 + entries.push({ collection, path, counts: paths[path] }); 33 + } 34 }); 35 }); 36 + return entries; 37 }; 38 39 + const BacklinkRecords = (props: BacklinksProps & { cursor?: string }) => { 40 + const [links, setLinks] = createSignal<LinksWithRecords>(); 41 + const [more, setMore] = createSignal(false); 42 43 + onMount(async () => { 44 + const res = await getRecordBacklinks(props.target, props.collection, props.path, props.cursor); 45 + setLinks(res); 46 + }); 47 48 return ( 49 + <Show when={links()} fallback={<p class="px-3 py-2 text-center text-neutral-500">Loadingโ€ฆ</p>}> 50 + <For each={links()!.linking_records}> 51 + {({ did, collection, rkey }) => { 52 + const timestamp = 53 + TID.validate(rkey) ? localDateFromTimestamp(TID.parse(rkey).timestamp / 1000) : null; 54 + const uri = `at://${did}/${collection}/${rkey}`; 55 + return ( 56 + <RecordHoverCard 57 + uri={uri} 58 + class="block" 59 + trigger={ 60 + <a 61 + href={`/${uri}`} 62 + class="grid grid-cols-[auto_1fr_auto] items-center gap-x-1 px-2 py-1.5 font-mono text-xs select-none hover:bg-neutral-200/50 sm:gap-x-3 sm:px-3 dark:hover:bg-neutral-700/50" 63 + > 64 + <span class="text-blue-500 dark:text-blue-400">{rkey}</span> 65 + <DidHoverCard 66 + did={did} 67 + class="min-w-0" 68 + trigger={ 69 + <a 70 + href={`/at://${did}`} 71 + class="block truncate text-neutral-700 hover:underline dark:text-neutral-300" 72 + onClick={(e) => e.stopPropagation()} 73 + > 74 + {did} 75 + </a> 76 + } 77 + /> 78 + <span class="text-neutral-500 tabular-nums dark:text-neutral-400"> 79 + {timestamp ?? ""} 80 + </span> 81 + </a> 82 + } 83 + /> 84 + ); 85 + }} 86 + </For> 87 + <Show when={links()?.cursor}> 88 + <Show 89 + when={more()} 90 + fallback={ 91 + <div class="p-2"> 92 + <Button 93 + onClick={() => setMore(true)} 94 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 w-full rounded-md border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 95 + > 96 + Load more 97 + </Button> 98 </div> 99 + } 100 + > 101 + <BacklinkRecords 102 + target={props.target} 103 + collection={props.collection} 104 + path={props.path} 105 + cursor={links()!.cursor} 106 + /> 107 + </Show> 108 </Show> 109 + </Show> 110 ); 111 }; 112 113 + const Backlinks = (props: { target: string }) => { 114 + const [response] = createResource(async () => { 115 + const res = await getAllBacklinks(props.target); 116 + return flattenLinks(res.links); 117 }); 118 119 return ( 120 + <div class="flex w-full flex-col gap-3 text-sm"> 121 + <Show when={response()} fallback={<p class="text-neutral-500">Loadingโ€ฆ</p>}> 122 + <Show when={response()!.length === 0}> 123 + <p class="text-neutral-500">No backlinks found.</p> 124 + </Show> 125 + <For each={response()}> 126 + {(entry) => ( 127 + <BacklinkSection 128 + target={props.target} 129 + collection={entry.collection} 130 + path={entry.path} 131 + counts={entry.counts} 132 + /> 133 )} 134 </For> 135 </Show> 136 + </div> 137 + ); 138 + }; 139 + 140 + const BacklinkSection = ( 141 + props: BacklinksProps & { counts: { distinct_dids: number; records: number } }, 142 + ) => { 143 + const [expanded, setExpanded] = createSignal(false); 144 + 145 + const authority = () => props.collection.split(".").slice(0, 2).join("."); 146 + 147 + return ( 148 + <div class="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-700"> 149 + <button 150 + class="flex w-full items-center justify-between gap-3 px-3 py-2 text-left hover:bg-neutral-50 dark:hover:bg-neutral-800/50" 151 + onClick={() => setExpanded(!expanded())} 152 + > 153 + <div class="flex min-w-0 flex-1 items-center gap-2"> 154 + <Favicon authority={authority()} /> 155 + <div class="flex min-w-0 flex-1 flex-col"> 156 + <span class="w-full truncate">{props.collection}</span> 157 + <span class="w-full text-xs wrap-break-word text-neutral-500 dark:text-neutral-400"> 158 + {props.path.slice(1)} 159 + </span> 160 + </div> 161 + </div> 162 + <div class="flex shrink-0 items-center gap-2 text-neutral-700 dark:text-neutral-300"> 163 + <span class="text-xs"> 164 + {props.counts.records} from {props.counts.distinct_dids} repo 165 + {props.counts.distinct_dids > 1 ? "s" : ""} 166 + </span> 167 + <span 168 + class="iconify lucide--chevron-down transition-transform" 169 + classList={{ "rotate-180": expanded() }} 170 /> 171 + </div> 172 + </button> 173 + <Show when={expanded()}> 174 + <div class="border-t border-neutral-200 bg-neutral-50/50 dark:border-neutral-700 dark:bg-neutral-800/30"> 175 + <BacklinkRecords target={props.target} collection={props.collection} path={props.path} /> 176 + </div> 177 </Show> 178 + </div> 179 ); 180 }; 181
+27 -7
src/auth/login.tsx
··· 29 30 31 32 33 34 35 36 37 38 ··· 46 47 48 49 - <label for="username" class="hidden"> 50 - Add account 51 - </label> 52 - <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"> 53 - <label 54 - for="username" 55 - class="iconify lucide--user-round-plus shrink-0 text-neutral-500 dark:text-neutral-400"
··· 29 30 31 32 + }; 33 34 + return ( 35 + <div class="flex flex-col gap-y-3"> 36 + <Show when={!scopeFlow.showScopeSelector()}> 37 + <Show when={props.onCancel}> 38 + <div class="flex items-center gap-2"> 39 + <button 40 + onclick={handleCancel} 41 + 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" 42 43 44 45 + <div class="font-semibold">Add account</div> 46 + </div> 47 + </Show> 48 + <form class="flex flex-col gap-3" onsubmit={(e) => e.preventDefault()}> 49 + <label for="username" class="hidden"> 50 + Add account 51 + </label> 52 + <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"> 53 + <label 54 + for="username" 55 + class="iconify lucide--user-round-plus shrink-0 text-neutral-500 dark:text-neutral-400" 56 57 58 ··· 66 67 68 69 + </div> 70 + <button 71 + onclick={() => initiateLogin(loginInput())} 72 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 73 + > 74 + Continue 75 + </button>
+1 -1
src/components/text-input.tsx
··· 25 disabled={props.disabled} 26 required={props.required} 27 class={ 28 - "dark:bg-dark-100 dark:inset-shadow-dark-200 rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 py-1 inset-shadow-xs select-none placeholder:text-sm focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400 " + 29 props.class 30 } 31 onInput={props.onInput}
··· 25 disabled={props.disabled} 26 required={props.required} 27 class={ 28 + "dark:bg-dark-100 rounded-md bg-white px-2 py-1 outline-1 outline-neutral-200 select-none placeholder:text-sm focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400 " + 29 props.class 30 } 31 onInput={props.onInput}
+64 -7
src/components/dropdown.tsx
··· 69 70 71 72 73 74 - 75 - export const ActionMenu = (props: { 76 - label: string; 77 - icon: string; 78 - onClick: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 79 - }) => { 80 return ( 81 <button 82 - onClick={props.onClick} 83 class="flex items-center gap-2 rounded-md p-1.5 whitespace-nowrap hover:bg-neutral-200/50 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 84 > 85 <Show when={props.icon}>
··· 69 70 71 72 + ); 73 + }; 74 75 + export const ActionMenu = (props: { label: string; icon: string; onClick: () => void }) => { 76 + const ctx = useContext(MenuContext); 77 78 return ( 79 <button 80 + onClick={() => { 81 + props.onClick(); 82 + ctx?.setShowMenu(false); 83 + }} 84 class="flex items-center gap-2 rounded-md p-1.5 whitespace-nowrap hover:bg-neutral-200/50 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 85 > 86 <Show when={props.icon}> 87 + 88 + 89 + 90 + 91 + 92 + 93 + 94 + 95 + 96 + 97 + 98 + 99 + 100 + 101 + 102 + 103 + 104 + const ctx = useContext(MenuContext); 105 + const [menu, setMenu] = createSignal<HTMLDivElement>(); 106 + const [menuButton, setMenuButton] = createSignal<HTMLButtonElement>(); 107 + const [buttonRect, setButtonRect] = createSignal<{ bottom: number; right: number }>(); 108 + 109 + const clickEvent = (event: MouseEvent) => { 110 + const target = event.target as Node; 111 + 112 + 113 + 114 + const updatePosition = () => { 115 + const rect = menuButton()?.getBoundingClientRect(); 116 + if (rect) { 117 + const isTouchDevice = window.matchMedia("(hover: none)").matches; 118 + const vv = isTouchDevice ? window.visualViewport : null; 119 + setButtonRect({ 120 + bottom: rect.bottom + (vv?.offsetTop ?? 0), 121 + right: rect.right + (vv?.offsetLeft ?? 0), 122 + }); 123 + } 124 + }; 125 + 126 + onMount(() => { 127 + window.addEventListener("click", clickEvent); 128 + window.addEventListener("scroll", updatePosition, true); 129 + window.addEventListener("resize", updatePosition); 130 + window.visualViewport?.addEventListener("resize", updatePosition); 131 + window.visualViewport?.addEventListener("scroll", updatePosition); 132 + }); 133 + 134 + onCleanup(() => { 135 + window.removeEventListener("click", clickEvent); 136 + window.removeEventListener("scroll", updatePosition, true); 137 + window.removeEventListener("resize", updatePosition); 138 + window.visualViewport?.removeEventListener("resize", updatePosition); 139 + window.visualViewport?.removeEventListener("scroll", updatePosition); 140 + }); 141 + 142 + return (
+25
src/utils/route-cache.ts
···
··· 1 + import { createStore } from "solid-js/store"; 2 + 3 + export interface CollectionCacheEntry { 4 + records: unknown[]; 5 + cursor: string | undefined; 6 + scrollY: number; 7 + reverse: boolean; 8 + limit: number; 9 + } 10 + 11 + type RouteCache = Record<string, CollectionCacheEntry>; 12 + 13 + const [routeCache, setRouteCache] = createStore<RouteCache>({}); 14 + 15 + export const getCollectionCache = (key: string): CollectionCacheEntry | undefined => { 16 + return routeCache[key]; 17 + }; 18 + 19 + export const setCollectionCache = (key: string, entry: CollectionCacheEntry): void => { 20 + setRouteCache(key, entry); 21 + }; 22 + 23 + export const clearCollectionCache = (key: string): void => { 24 + setRouteCache(key, undefined!); 25 + };
+11 -12
src/components/theme.tsx
··· 8 9 export const ThemeSelection = () => { 10 const [theme, setTheme] = createSignal( 11 - localStorage.getItem("theme") === null ? "system" 12 : localStorage.theme === "dark" ? "dark" 13 : "light", 14 ); ··· 18 document.documentElement.classList.toggle( 19 "dark", 20 newTheme === "dark" || 21 - (newTheme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches), 22 ); 23 - if (newTheme === "system") localStorage.removeItem("theme"); 24 else localStorage.theme = newTheme; 25 }; 26 27 - const ThemeOption = (props: { theme: string; icon: string; label: string }) => { 28 return ( 29 <button 30 classList={{ 31 - "flex items-center gap-2 rounded-xl border px-3 py-2": true, 32 "bg-neutral-200/60 border-neutral-300 dark:border-neutral-500 dark:bg-neutral-700": 33 theme() === props.theme, 34 "border-neutral-200 dark:border-neutral-600 hover:bg-neutral-200/30 dark:hover:bg-neutral-800": ··· 36 }} 37 onclick={() => updateTheme(props.theme)} 38 > 39 - <span class={"iconify " + props.icon}></span> 40 - <span>{props.label}</span> 41 </button> 42 ); 43 }; 44 45 return ( 46 - <div class="flex flex-col gap-0.5"> 47 - <label class="select-none">Theme</label> 48 <div class="flex gap-2"> 49 - <ThemeOption theme="system" icon="lucide--monitor" label="System" /> 50 - <ThemeOption theme="light" icon="lucide--sun" label="Light" /> 51 - <ThemeOption theme="dark" icon="lucide--moon" label="Dark" /> 52 </div> 53 </div> 54 );
··· 8 9 export const ThemeSelection = () => { 10 const [theme, setTheme] = createSignal( 11 + localStorage.getItem("theme") === null ? "auto" 12 : localStorage.theme === "dark" ? "dark" 13 : "light", 14 ); ··· 18 document.documentElement.classList.toggle( 19 "dark", 20 newTheme === "dark" || 21 + (newTheme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches), 22 ); 23 + if (newTheme === "auto") localStorage.removeItem("theme"); 24 else localStorage.theme = newTheme; 25 }; 26 27 + const ThemeOption = (props: { theme: string; label: string }) => { 28 return ( 29 <button 30 classList={{ 31 + "flex items-center min-w-21 justify-center rounded-xl border px-3 py-2": true, 32 "bg-neutral-200/60 border-neutral-300 dark:border-neutral-500 dark:bg-neutral-700": 33 theme() === props.theme, 34 "border-neutral-200 dark:border-neutral-600 hover:bg-neutral-200/30 dark:hover:bg-neutral-800": ··· 36 }} 37 onclick={() => updateTheme(props.theme)} 38 > 39 + {props.label} 40 </button> 41 ); 42 }; 43 44 return ( 45 + <div class="flex flex-col gap-1"> 46 + <label class="font-medium select-none">Theme</label> 47 <div class="flex gap-2"> 48 + <ThemeOption theme="auto" label="Auto" /> 49 + <ThemeOption theme="light" label="Light" /> 50 + <ThemeOption theme="dark" label="Dark" /> 51 </div> 52 </div> 53 );
public/ribbon.webp

This is a binary file and will not be displayed.

+4 -5
index.html
··· 3 4 5 6 - 7 - 8 - 9 - 10 <meta property="og:description" content="Browse the public data on atproto" /> 11 <meta property="description" content="Browse the public data on atproto" /> 12 <link rel="manifest" href="/manifest.json" /> 13 - <title>PDSls</title> 14 <link rel="preconnect" href="https://fonts.bunny.net" /> 15 <link href="https://fonts.bunny.net/css?family=roboto-mono:400" rel="stylesheet" /> 16 <link href="https://fonts.cdnfonts.com/css/pecita" rel="stylesheet" />
··· 3 4 5 6 + <link rel="icon" href="/favicon.ico" /> 7 + <meta property="og:title" content="PDSls" /> 8 + <meta property="og:type" content="website" /> 9 + <meta property="og:url" content="https://pds.ls" /> 10 <meta property="og:description" content="Browse the public data on atproto" /> 11 <meta property="description" content="Browse the public data on atproto" /> 12 <link rel="manifest" href="/manifest.json" /> 13 <link rel="preconnect" href="https://fonts.bunny.net" /> 14 <link href="https://fonts.bunny.net/css?family=roboto-mono:400" rel="stylesheet" /> 15 <link href="https://fonts.cdnfonts.com/css/pecita" rel="stylesheet" />
+123 -119
src/views/stream.tsx
··· 1 import { Firehose } from "@skyware/firehose"; 2 import { A, useLocation, useSearchParams } from "@solidjs/router"; 3 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 4 import { Button } from "../components/button"; ··· 169 }); 170 171 return ( 172 - <div class="flex w-full flex-col items-center"> 173 - <div class="mb-1 flex gap-4 font-medium"> 174 - <A 175 - class="flex items-center gap-1 border-b-2" 176 - inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 177 - href="/jetstream" 178 - > 179 - Jetstream 180 - </A> 181 - <A 182 - class="flex items-center gap-1 border-b-2" 183 - inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 184 - href="/firehose" 185 - > 186 - Firehose 187 - </A> 188 - </div> 189 - <StickyOverlay> 190 - <form ref={formRef} class="flex w-full flex-col gap-1.5 text-sm"> 191 - <Show when={!connected()}> 192 - <label class="flex items-center justify-end gap-x-1"> 193 - <span class="min-w-20">Instance</span> 194 - <TextInput 195 - name="instance" 196 - value={ 197 - searchParams.instance ?? 198 - (streamType === "jetstream" ? 199 - "wss://jetstream1.us-east.bsky.network/subscribe" 200 - : "wss://bsky.network") 201 - } 202 - class="grow" 203 - /> 204 - </label> 205 - <Show when={streamType === "jetstream"}> 206 <label class="flex items-center justify-end gap-x-1"> 207 - <span class="min-w-20">Collections</span> 208 - <textarea 209 - name="collections" 210 - spellcheck={false} 211 - placeholder="Comma-separated list of collections" 212 - value={searchParams.collections ?? ""} 213 - class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 214 /> 215 </label> 216 - </Show> 217 - <Show when={streamType === "jetstream"}> 218 <label class="flex items-center justify-end gap-x-1"> 219 - <span class="min-w-20">DIDs</span> 220 - <textarea 221 - name="dids" 222 - spellcheck={false} 223 - placeholder="Comma-separated list of DIDs" 224 - value={searchParams.dids ?? ""} 225 - class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 226 /> 227 </label> 228 </Show> 229 - <label class="flex items-center justify-end gap-x-1"> 230 - <span class="min-w-20">Cursor</span> 231 - <TextInput 232 - name="cursor" 233 - placeholder="Leave empty for live-tail" 234 - value={searchParams.cursor ?? ""} 235 - class="grow" 236 - /> 237 - </label> 238 - <Show when={streamType === "jetstream"}> 239 - <div class="flex items-center justify-end gap-x-1"> 240 - <input 241 - type="checkbox" 242 - name="allEvents" 243 - id="allEvents" 244 - checked={searchParams.allEvents === "on" ? true : false} 245 - /> 246 - <label for="allEvents" class="select-none"> 247 - Show account and identity events 248 - </label> 249 - </div> 250 - </Show> 251 - </Show> 252 - <Show when={connected()}> 253 - <div class="flex flex-col gap-1 wrap-anywhere"> 254 - <For each={parameters()}> 255 - {(param) => ( 256 - <Show when={param.param}> 257 - <div class="flex"> 258 - <div class="min-w-24 font-semibold">{param.name}</div> 259 - {param.param} 260 - </div> 261 - </Show> 262 - )} 263 - </For> 264 - </div> 265 - </Show> 266 - <div class="flex justify-end"> 267 <Show when={connected()}> 268 - <button 269 - type="button" 270 - onmousedown={(e) => { 271 - e.preventDefault(); 272 - disconnect(); 273 - }} 274 - ontouchstart={(e) => { 275 - e.preventDefault(); 276 - disconnect(); 277 - }} 278 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-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" 279 - > 280 - Disconnect 281 - </button> 282 - </Show> 283 - <Show when={!connected()}> 284 - <Button onClick={() => connectSocket(new FormData(formRef))}>Connect</Button> 285 </Show> 286 - </div> 287 - </form> 288 - </StickyOverlay> 289 - <Show when={notice().length}> 290 - <div class="text-red-500 dark:text-red-400">{notice()}</div> 291 - </Show> 292 - <div class="flex w-full flex-col gap-2 divide-y-[0.5px] divide-neutral-500 font-mono text-sm wrap-anywhere whitespace-pre-wrap md:w-3xl"> 293 - <For each={records().toReversed()}> 294 - {(rec) => ( 295 - <div class="pb-2"> 296 - <JSONValue data={rec} repo={rec.did ?? rec.repo} /> 297 </div> 298 - )} 299 - </For> 300 </div> 301 - </div> 302 ); 303 }; 304
··· 1 import { Firehose } from "@skyware/firehose"; 2 + import { Title } from "@solidjs/meta"; 3 import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 import { Button } from "../components/button"; ··· 170 }); 171 172 return ( 173 + <> 174 + <Title>{streamType === "firehose" ? "Firehose" : "Jetstream"} - PDSls</Title> 175 + <div class="flex w-full flex-col items-center"> 176 + <div class="mb-1 flex gap-4 font-medium"> 177 + <A 178 + class="flex items-center gap-1 border-b-2" 179 + inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 180 + href="/jetstream" 181 + > 182 + Jetstream 183 + </A> 184 + <A 185 + class="flex items-center gap-1 border-b-2" 186 + inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 187 + href="/firehose" 188 + > 189 + Firehose 190 + </A> 191 + </div> 192 + <StickyOverlay> 193 + <form ref={formRef} class="flex w-full flex-col gap-1.5 text-sm"> 194 + <Show when={!connected()}> 195 <label class="flex items-center justify-end gap-x-1"> 196 + <span class="min-w-20">Instance</span> 197 + <TextInput 198 + name="instance" 199 + value={ 200 + searchParams.instance ?? 201 + (streamType === "jetstream" ? 202 + "wss://jetstream1.us-east.bsky.network/subscribe" 203 + : "wss://bsky.network") 204 + } 205 + class="grow" 206 /> 207 </label> 208 + <Show when={streamType === "jetstream"}> 209 + <label class="flex items-center justify-end gap-x-1"> 210 + <span class="min-w-20">Collections</span> 211 + <textarea 212 + name="collections" 213 + spellcheck={false} 214 + placeholder="Comma-separated list of collections" 215 + value={searchParams.collections ?? ""} 216 + class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 217 + /> 218 + </label> 219 + </Show> 220 + <Show when={streamType === "jetstream"}> 221 + <label class="flex items-center justify-end gap-x-1"> 222 + <span class="min-w-20">DIDs</span> 223 + <textarea 224 + name="dids" 225 + spellcheck={false} 226 + placeholder="Comma-separated list of DIDs" 227 + value={searchParams.dids ?? ""} 228 + class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 229 + /> 230 + </label> 231 + </Show> 232 <label class="flex items-center justify-end gap-x-1"> 233 + <span class="min-w-20">Cursor</span> 234 + <TextInput 235 + name="cursor" 236 + placeholder="Leave empty for live-tail" 237 + value={searchParams.cursor ?? ""} 238 + class="grow" 239 /> 240 </label> 241 + <Show when={streamType === "jetstream"}> 242 + <div class="flex items-center justify-end gap-x-1"> 243 + <input 244 + type="checkbox" 245 + name="allEvents" 246 + id="allEvents" 247 + checked={searchParams.allEvents === "on" ? true : false} 248 + /> 249 + <label for="allEvents" class="select-none"> 250 + Show account and identity events 251 + </label> 252 + </div> 253 + </Show> 254 </Show> 255 <Show when={connected()}> 256 + <div class="flex flex-col gap-1 wrap-anywhere"> 257 + <For each={parameters()}> 258 + {(param) => ( 259 + <Show when={param.param}> 260 + <div class="flex"> 261 + <div class="min-w-24 font-semibold">{param.name}</div> 262 + {param.param} 263 + </div> 264 + </Show> 265 + )} 266 + </For> 267 + </div> 268 </Show> 269 + <div class="flex justify-end"> 270 + <Show when={connected()}> 271 + <button 272 + type="button" 273 + onmousedown={(e) => { 274 + e.preventDefault(); 275 + disconnect(); 276 + }} 277 + ontouchstart={(e) => { 278 + e.preventDefault(); 279 + disconnect(); 280 + }} 281 + class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-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" 282 + > 283 + Disconnect 284 + </button> 285 + </Show> 286 + <Show when={!connected()}> 287 + <Button onClick={() => connectSocket(new FormData(formRef))}>Connect</Button> 288 + </Show> 289 </div> 290 + </form> 291 + </StickyOverlay> 292 + <Show when={notice().length}> 293 + <div class="text-red-500 dark:text-red-400">{notice()}</div> 294 + </Show> 295 + <div class="flex w-full flex-col gap-2 divide-y-[0.5px] divide-neutral-500 font-mono text-sm wrap-anywhere whitespace-pre-wrap md:w-3xl"> 296 + <For each={records().toReversed()}> 297 + {(rec) => ( 298 + <div class="pb-2"> 299 + <JSONValue data={rec} repo={rec.did ?? rec.repo} /> 300 + </div> 301 + )} 302 + </For> 303 + </div> 304 </div> 305 + </> 306 ); 307 }; 308
+649
src/views/car.tsx
···
··· 1 + import * as CAR from "@atcute/car"; 2 + import * as CBOR from "@atcute/cbor"; 3 + import * as CID from "@atcute/cid"; 4 + import { fromStream, isCommit } from "@atcute/repo"; 5 + import * as TID from "@atcute/tid"; 6 + import { Title } from "@solidjs/meta"; 7 + import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"; 8 + import { Button } from "../components/button.jsx"; 9 + import { JSONValue, type JSONType } from "../components/json.jsx"; 10 + import { TextInput } from "../components/text-input.jsx"; 11 + import { isTouchDevice } from "../layout.jsx"; 12 + import { localDateFromTimestamp } from "../utils/date.js"; 13 + 14 + const isIOS = 15 + /iPad|iPhone|iPod/.test(navigator.userAgent) || 16 + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); 17 + 18 + // Convert CBOR-decoded objects to JSON-friendly format 19 + const toJsonValue = (obj: unknown): JSONType => { 20 + if (obj === null || obj === undefined) return null; 21 + 22 + if (CID.isCidLink(obj)) { 23 + return { $link: obj.$link }; 24 + } 25 + 26 + if ( 27 + obj && 28 + typeof obj === "object" && 29 + "version" in obj && 30 + "codec" in obj && 31 + "digest" in obj && 32 + "bytes" in obj 33 + ) { 34 + try { 35 + return { $link: CID.toString(obj as CID.Cid) }; 36 + } catch {} 37 + } 38 + 39 + if (CBOR.isBytes(obj)) { 40 + return { $bytes: obj.$bytes }; 41 + } 42 + 43 + if (Array.isArray(obj)) { 44 + return obj.map(toJsonValue); 45 + } 46 + 47 + if (typeof obj === "object") { 48 + const result: Record<string, JSONType> = {}; 49 + for (const [key, value] of Object.entries(obj)) { 50 + result[key] = toJsonValue(value); 51 + } 52 + return result; 53 + } 54 + 55 + return obj as JSONType; 56 + }; 57 + 58 + interface Archive { 59 + file: File; 60 + did: string; 61 + entries: CollectionEntry[]; 62 + } 63 + 64 + interface CollectionEntry { 65 + name: string; 66 + entries: RecordEntry[]; 67 + } 68 + 69 + interface RecordEntry { 70 + key: string; 71 + cid: string; 72 + record: JSONType; 73 + } 74 + 75 + type View = 76 + | { type: "repo" } 77 + | { type: "collection"; collection: CollectionEntry } 78 + | { type: "record"; collection: CollectionEntry; record: RecordEntry }; 79 + 80 + export const CarView = () => { 81 + const [archive, setArchive] = createSignal<Archive | null>(null); 82 + const [loading, setLoading] = createSignal(false); 83 + const [error, setError] = createSignal<string>(); 84 + const [view, setView] = createSignal<View>({ type: "repo" }); 85 + 86 + const parseCarFile = async (file: File) => { 87 + setLoading(true); 88 + setError(undefined); 89 + 90 + try { 91 + // Read file as ArrayBuffer to extract DID from commit block 92 + const buffer = new Uint8Array(await file.arrayBuffer()); 93 + const car = CAR.fromUint8Array(buffer); 94 + 95 + // Get DID from commit block 96 + let did = ""; 97 + const rootCid = car.roots[0]?.$link; 98 + if (rootCid) { 99 + for (const entry of car) { 100 + if (CID.toString(entry.cid) === rootCid) { 101 + const commit = CBOR.decode(entry.bytes); 102 + if (isCommit(commit)) { 103 + did = commit.did; 104 + } 105 + break; 106 + } 107 + } 108 + } 109 + 110 + const collections = new Map<string, RecordEntry[]>(); 111 + const result: Archive = { 112 + file, 113 + did, 114 + entries: [], 115 + }; 116 + 117 + const stream = file.stream(); 118 + const repo = fromStream(stream); 119 + try { 120 + for await (const entry of repo) { 121 + let list = collections.get(entry.collection); 122 + if (list === undefined) { 123 + collections.set(entry.collection, (list = [])); 124 + result.entries.push({ 125 + name: entry.collection, 126 + entries: list, 127 + }); 128 + } 129 + 130 + const record = toJsonValue(entry.record); 131 + list.push({ 132 + key: entry.rkey, 133 + cid: entry.cid.$link, 134 + record, 135 + }); 136 + } 137 + } finally { 138 + await repo.dispose(); 139 + } 140 + 141 + setArchive(result); 142 + setView({ type: "repo" }); 143 + } catch (err) { 144 + console.error("Failed to parse CAR file:", err); 145 + setError(err instanceof Error ? err.message : "Failed to parse CAR file"); 146 + } finally { 147 + setLoading(false); 148 + } 149 + }; 150 + 151 + const handleFileChange = (e: Event) => { 152 + const input = e.target as HTMLInputElement; 153 + const file = input.files?.[0]; 154 + if (file) { 155 + parseCarFile(file); 156 + } 157 + }; 158 + 159 + const handleDrop = (e: DragEvent) => { 160 + e.preventDefault(); 161 + const file = e.dataTransfer?.files?.[0]; 162 + if (file && (file.name.endsWith(".car") || file.type === "application/vnd.ipld.car")) { 163 + parseCarFile(file); 164 + } 165 + }; 166 + 167 + const handleDragOver = (e: DragEvent) => { 168 + e.preventDefault(); 169 + }; 170 + 171 + const reset = () => { 172 + setArchive(null); 173 + setView({ type: "repo" }); 174 + setError(undefined); 175 + }; 176 + 177 + return ( 178 + <> 179 + <Title>CAR explorer - PDSls</Title> 180 + <div class="flex w-full flex-col items-center"> 181 + <Show 182 + when={archive()} 183 + fallback={ 184 + <WelcomeView 185 + loading={loading()} 186 + error={error()} 187 + onFileChange={handleFileChange} 188 + onDrop={handleDrop} 189 + onDragOver={handleDragOver} 190 + /> 191 + } 192 + > 193 + {(arch) => <ExploreView archive={arch()} view={view} setView={setView} onClose={reset} />} 194 + </Show> 195 + </div> 196 + </> 197 + ); 198 + }; 199 + 200 + const WelcomeView = (props: { 201 + loading: boolean; 202 + error?: string; 203 + onFileChange: (e: Event) => void; 204 + onDrop: (e: DragEvent) => void; 205 + onDragOver: (e: DragEvent) => void; 206 + }) => { 207 + return ( 208 + <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 209 + <div class="flex flex-col gap-y-1"> 210 + <h1 class="text-lg font-semibold">CAR explorer</h1> 211 + <p class="text-sm text-neutral-600 dark:text-neutral-400"> 212 + Upload a CAR (Content Addressable aRchive) file to explore its contents. 213 + </p> 214 + </div> 215 + 216 + <div 217 + class="dark:bg-dark-300 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 p-8 transition-colors hover:border-neutral-400 dark:border-neutral-600 dark:hover:border-neutral-500" 218 + onDrop={props.onDrop} 219 + onDragOver={props.onDragOver} 220 + > 221 + <Show 222 + when={!props.loading} 223 + fallback={ 224 + <div class="flex flex-col items-center gap-2"> 225 + <span class="iconify lucide--loader-circle animate-spin text-3xl text-neutral-400" /> 226 + <span class="text-sm font-medium text-neutral-600 dark:text-neutral-400"> 227 + Reading CAR file... 228 + </span> 229 + </div> 230 + } 231 + > 232 + <span class="iconify lucide--folder-archive text-3xl text-neutral-400" /> 233 + <div class="text-center"> 234 + <p class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 235 + Drag and drop a CAR file here 236 + </p> 237 + <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 238 + </div> 239 + <label class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-8 items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-3 py-1.5 text-sm shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"> 240 + <input 241 + type="file" 242 + accept={isIOS ? undefined : ".car,application/vnd.ipld.car"} 243 + onChange={props.onFileChange} 244 + class="hidden" 245 + /> 246 + <span class="iconify lucide--upload text-sm" /> 247 + Choose file 248 + </label> 249 + </Show> 250 + </div> 251 + 252 + <Show when={props.error}> 253 + <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"> 254 + {props.error} 255 + </div> 256 + </Show> 257 + </div> 258 + ); 259 + }; 260 + 261 + const ExploreView = (props: { 262 + archive: Archive; 263 + view: () => View; 264 + setView: (view: View) => void; 265 + onClose: () => void; 266 + }) => { 267 + return ( 268 + <div class="flex w-full flex-col"> 269 + <nav class="flex w-full flex-col text-sm wrap-anywhere sm:text-base"> 270 + {/* DID / Repository Level */} 271 + <div class="group relative flex items-center justify-between gap-1 rounded-md border-[0.5px] border-transparent bg-transparent transition-all duration-200 hover:border-neutral-300 hover:bg-neutral-50/40 dark:hover:border-neutral-600 dark:hover:bg-neutral-800/40"> 272 + <Show 273 + when={props.view().type !== "repo"} 274 + fallback={ 275 + <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 px-2 sm:min-h-7"> 276 + <span class="iconify lucide--book-user shrink-0 text-neutral-500 transition-colors duration-200 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-200" /> 277 + <span class="truncate py-0.5 font-medium">{props.archive.did || "Repository"}</span> 278 + </div> 279 + } 280 + > 281 + <button 282 + type="button" 283 + onClick={() => props.setView({ type: "repo" })} 284 + class="flex min-h-6 min-w-0 basis-full items-center gap-2 px-2 sm:min-h-7" 285 + > 286 + <span class="iconify lucide--book-user shrink-0 text-neutral-500 transition-colors duration-200 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-200" /> 287 + <span class="truncate py-0.5 font-medium text-blue-400 transition-colors duration-150 group-hover:text-blue-500 dark:group-hover:text-blue-300"> 288 + {props.archive.did || "Repository"} 289 + </span> 290 + </button> 291 + </Show> 292 + <button 293 + type="button" 294 + onClick={props.onClose} 295 + title="Close and upload a different file" 296 + class="flex shrink-0 items-center rounded px-2 py-1 text-neutral-500 transition-all duration-200 hover:bg-neutral-200/70 hover:text-neutral-600 active:bg-neutral-300/70 sm:py-1.5 dark:text-neutral-400 dark:hover:bg-neutral-700/70 dark:hover:text-neutral-300 dark:active:bg-neutral-600/70" 297 + > 298 + <span class="iconify lucide--x" /> 299 + </button> 300 + </div> 301 + 302 + {/* Collection Level */} 303 + <Show 304 + when={(() => { 305 + const v = props.view(); 306 + return v.type === "collection" || v.type === "record" ? v.collection : null; 307 + })()} 308 + > 309 + {(collection) => ( 310 + <Show 311 + when={props.view().type === "record"} 312 + fallback={ 313 + <div class="group relative flex items-center justify-between gap-1 rounded-md border-[0.5px] border-transparent bg-transparent px-2 transition-all duration-200 hover:border-neutral-300 hover:bg-neutral-50/40 dark:hover:border-neutral-600 dark:hover:bg-neutral-800/40"> 314 + <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 sm:min-h-7"> 315 + <span class="iconify lucide--folder-open shrink-0 text-neutral-500 transition-colors duration-200 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-200" /> 316 + <span class="truncate py-0.5 font-medium">{collection().name}</span> 317 + </div> 318 + </div> 319 + } 320 + > 321 + <button 322 + type="button" 323 + onClick={() => props.setView({ type: "collection", collection: collection() })} 324 + class="group relative flex w-full items-center justify-between gap-1 rounded-md border-[0.5px] border-transparent bg-transparent px-2 transition-all duration-200 hover:border-neutral-300 hover:bg-neutral-50/40 dark:hover:border-neutral-600 dark:hover:bg-neutral-800/40" 325 + > 326 + <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 sm:min-h-7"> 327 + <span class="iconify lucide--folder-open shrink-0 text-neutral-500 transition-colors duration-200 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-200" /> 328 + <span class="truncate py-0.5 font-medium text-blue-400 transition-colors duration-150 group-hover:text-blue-500 dark:group-hover:text-blue-300"> 329 + {collection().name} 330 + </span> 331 + </div> 332 + </button> 333 + </Show> 334 + )} 335 + </Show> 336 + 337 + {/* Record Level */} 338 + <Show 339 + when={(() => { 340 + const v = props.view(); 341 + return v.type === "record" ? v.record : null; 342 + })()} 343 + > 344 + {(record) => ( 345 + <div class="group relative flex items-center justify-between gap-1 rounded-md border-[0.5px] border-transparent bg-transparent px-2 transition-all duration-200 hover:border-neutral-300 hover:bg-neutral-50/40 dark:hover:border-neutral-600 dark:hover:bg-neutral-800/40"> 346 + <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 sm:min-h-7"> 347 + <span class="iconify lucide--file-json shrink-0 text-neutral-500 transition-colors duration-200 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-200" /> 348 + <span class="truncate py-0.5 font-medium">{record().key}</span> 349 + </div> 350 + </div> 351 + )} 352 + </Show> 353 + </nav> 354 + 355 + <div class="px-2 py-2"> 356 + <Switch> 357 + <Match when={props.view().type === "repo"}> 358 + <RepoSubview archive={props.archive} onRoute={props.setView} /> 359 + </Match> 360 + 361 + <Match 362 + when={(() => { 363 + const v = props.view(); 364 + return v.type === "collection" ? v : null; 365 + })()} 366 + keyed 367 + > 368 + {({ collection }) => ( 369 + <CollectionSubview 370 + archive={props.archive} 371 + collection={collection} 372 + onRoute={props.setView} 373 + /> 374 + )} 375 + </Match> 376 + 377 + <Match 378 + when={(() => { 379 + const v = props.view(); 380 + return v.type === "record" ? v : null; 381 + })()} 382 + keyed 383 + > 384 + {({ collection, record }) => ( 385 + <RecordSubview archive={props.archive} collection={collection} record={record} /> 386 + )} 387 + </Match> 388 + </Switch> 389 + </div> 390 + </div> 391 + ); 392 + }; 393 + 394 + const RepoSubview = (props: { archive: Archive; onRoute: (view: View) => void }) => { 395 + const [filter, setFilter] = createSignal(""); 396 + 397 + const sortedEntries = createMemo(() => { 398 + return [...props.archive.entries].sort((a, b) => a.name.localeCompare(b.name)); 399 + }); 400 + 401 + const filteredEntries = createMemo(() => { 402 + const f = filter().toLowerCase().trim(); 403 + if (!f) return sortedEntries(); 404 + return sortedEntries().filter((entry) => entry.name.toLowerCase().includes(f)); 405 + }); 406 + 407 + const totalRecords = createMemo(() => 408 + props.archive.entries.reduce((sum, entry) => sum + entry.entries.length, 0), 409 + ); 410 + 411 + return ( 412 + <div class="flex flex-col gap-3"> 413 + <div class="text-sm text-neutral-600 dark:text-neutral-400"> 414 + {props.archive.entries.length} collection{props.archive.entries.length > 1 ? "s" : ""} 415 + <span class="text-neutral-400 dark:text-neutral-600"> ยท </span> 416 + {totalRecords()} record{totalRecords() > 1 ? "s" : ""} 417 + </div> 418 + 419 + <TextInput 420 + placeholder="Filter collections" 421 + value={filter()} 422 + onInput={(e) => setFilter(e.currentTarget.value)} 423 + class="text-sm" 424 + /> 425 + 426 + <ul class="flex flex-col"> 427 + <For each={filteredEntries()}> 428 + {(entry) => { 429 + const hasSingleEntry = entry.entries.length === 1; 430 + 431 + return ( 432 + <li> 433 + <button 434 + onClick={() => { 435 + if (hasSingleEntry) { 436 + props.onRoute({ 437 + type: "record", 438 + collection: entry, 439 + record: entry.entries[0], 440 + }); 441 + } else { 442 + props.onRoute({ type: "collection", collection: entry }); 443 + } 444 + }} 445 + class="flex w-full items-center gap-2 rounded p-2 text-left text-sm hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-800 dark:active:bg-neutral-700" 446 + > 447 + <span 448 + class="truncate font-medium" 449 + classList={{ 450 + "text-neutral-700 dark:text-neutral-300": hasSingleEntry, 451 + "text-blue-400": !hasSingleEntry, 452 + }} 453 + > 454 + {entry.name} 455 + </span> 456 + 457 + <Show when={hasSingleEntry}> 458 + <span class="iconify lucide--chevron-right shrink-0 text-xs text-neutral-500" /> 459 + <span class="truncate font-medium text-blue-400">{entry.entries[0].key}</span> 460 + </Show> 461 + 462 + <Show when={!hasSingleEntry}> 463 + <span class="ml-auto text-xs text-neutral-500">{entry.entries.length}</span> 464 + </Show> 465 + </button> 466 + </li> 467 + ); 468 + }} 469 + </For> 470 + </ul> 471 + 472 + <Show when={filteredEntries().length === 0 && filter()}> 473 + <div class="flex flex-col items-center justify-center py-8 text-center"> 474 + <span class="iconify lucide--search-x mb-2 text-3xl text-neutral-400" /> 475 + <p class="text-sm text-neutral-600 dark:text-neutral-400"> 476 + No collections match your filter 477 + </p> 478 + </div> 479 + </Show> 480 + </div> 481 + ); 482 + }; 483 + 484 + const RECORDS_PER_PAGE = 100; 485 + 486 + const CollectionSubview = (props: { 487 + archive: Archive; 488 + collection: CollectionEntry; 489 + onRoute: (view: View) => void; 490 + }) => { 491 + const [filter, setFilter] = createSignal(""); 492 + const [displayCount, setDisplayCount] = createSignal(RECORDS_PER_PAGE); 493 + 494 + // Sort entries by TID timestamp (most recent first), non-TID entries go to the end 495 + const sortedEntries = createMemo(() => { 496 + return [...props.collection.entries].sort((a, b) => { 497 + const aIsTid = TID.validate(a.key); 498 + const bIsTid = TID.validate(b.key); 499 + 500 + if (aIsTid && bIsTid) { 501 + return TID.parse(b.key).timestamp - TID.parse(a.key).timestamp; 502 + } 503 + if (aIsTid) return -1; 504 + if (bIsTid) return 1; 505 + return b.key.localeCompare(a.key); 506 + }); 507 + }); 508 + 509 + const filteredEntries = createMemo(() => { 510 + const f = filter().toLowerCase().trim(); 511 + if (!f) return sortedEntries(); 512 + return sortedEntries().filter((entry) => 513 + JSON.stringify(entry.record).toLowerCase().includes(f), 514 + ); 515 + }); 516 + 517 + const displayedEntries = createMemo(() => { 518 + return filteredEntries().slice(0, displayCount()); 519 + }); 520 + 521 + const hasMore = createMemo(() => filteredEntries().length > displayCount()); 522 + 523 + const loadMore = () => { 524 + setDisplayCount((prev) => prev + RECORDS_PER_PAGE); 525 + }; 526 + 527 + return ( 528 + <div class="flex flex-col gap-3"> 529 + <span class="text-sm text-neutral-600 dark:text-neutral-400"> 530 + {filteredEntries().length} record{filteredEntries().length > 1 ? "s" : ""} 531 + {filter() && filteredEntries().length !== props.collection.entries.length && ( 532 + <span class="text-neutral-400 dark:text-neutral-500"> 533 + {" "} 534 + (of {props.collection.entries.length}) 535 + </span> 536 + )} 537 + </span> 538 + 539 + <div class="flex items-center gap-2"> 540 + <TextInput 541 + placeholder="Filter records" 542 + value={filter()} 543 + onInput={(e) => { 544 + setFilter(e.currentTarget.value); 545 + setDisplayCount(RECORDS_PER_PAGE); 546 + }} 547 + class="grow text-sm" 548 + /> 549 + 550 + <Show when={hasMore()}> 551 + <span class="text-sm text-neutral-600 dark:text-neutral-400"> 552 + {displayedEntries().length}/{filteredEntries().length} 553 + </span> 554 + 555 + <Button onClick={loadMore}>Load More</Button> 556 + </Show> 557 + </div> 558 + 559 + <div class="flex flex-col font-mono"> 560 + <For each={displayedEntries()}> 561 + {(entry) => { 562 + const isTid = TID.validate(entry.key); 563 + const timestamp = isTid ? TID.parse(entry.key).timestamp / 1_000 : null; 564 + const [hover, setHover] = createSignal(false); 565 + const [previewHeight, setPreviewHeight] = createSignal(0); 566 + let rkeyRef!: HTMLButtonElement; 567 + let previewRef!: HTMLSpanElement; 568 + 569 + createEffect(() => { 570 + if (hover()) setPreviewHeight(previewRef.offsetHeight); 571 + }); 572 + 573 + const isOverflowing = (previewHeight: number) => 574 + rkeyRef.offsetTop - window.scrollY + previewHeight + 32 > window.innerHeight; 575 + 576 + return ( 577 + <button 578 + onClick={() => { 579 + props.onRoute({ 580 + type: "record", 581 + collection: props.collection, 582 + record: entry, 583 + }); 584 + }} 585 + ref={rkeyRef} 586 + onmouseover={() => !isTouchDevice && setHover(true)} 587 + onmouseleave={() => !isTouchDevice && setHover(false)} 588 + class="relative flex w-full items-baseline gap-1 rounded px-1 py-0.5 text-left hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 589 + > 590 + <span class="shrink-0 text-sm text-blue-400">{entry.key}</span> 591 + <span class="truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 592 + {entry.cid} 593 + </span> 594 + <Show when={timestamp}> 595 + {(ts) => ( 596 + <span class="ml-auto shrink-0 text-xs">{localDateFromTimestamp(ts())}</span> 597 + )} 598 + </Show> 599 + <Show when={hover()}> 600 + <span 601 + ref={previewRef} 602 + class={`dark:bg-dark-300 dark:shadow-dark-700 pointer-events-none absolute left-[50%] z-25 block max-h-80 w-max max-w-sm -translate-x-1/2 overflow-hidden rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 text-xs whitespace-pre-wrap shadow-md sm:max-h-112 lg:max-w-lg dark:border-neutral-700 ${isOverflowing(previewHeight()) ? "bottom-7" : "top-7"}`} 603 + > 604 + <JSONValue data={entry.record} repo={props.archive.did} truncate /> 605 + </span> 606 + </Show> 607 + </button> 608 + ); 609 + }} 610 + </For> 611 + </div> 612 + 613 + <Show when={filteredEntries().length === 0 && filter()}> 614 + <div class="flex flex-col items-center justify-center py-8 text-center"> 615 + <span class="iconify lucide--search-x mb-2 text-3xl text-neutral-400" /> 616 + <p class="text-sm text-neutral-600 dark:text-neutral-400">No records match your filter</p> 617 + </div> 618 + </Show> 619 + </div> 620 + ); 621 + }; 622 + 623 + const RecordSubview = (props: { 624 + archive: Archive; 625 + collection: CollectionEntry; 626 + record: RecordEntry; 627 + }) => { 628 + return ( 629 + <div class="flex flex-col items-center gap-3"> 630 + <div class="flex w-full items-center gap-2 text-sm text-neutral-600 sm:text-base dark:text-neutral-400"> 631 + <span class="iconify lucide--box shrink-0" /> 632 + <span class="text-xs break-all">{props.record.cid}</span> 633 + </div> 634 + 635 + <Show 636 + when={props.record.record !== null} 637 + fallback={ 638 + <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"> 639 + Failed to decode record 640 + </div> 641 + } 642 + > 643 + <div class="max-w-full min-w-full font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:w-max sm:max-w-screen sm:px-4 sm:text-sm md:max-w-3xl"> 644 + <JSONValue data={props.record.record} repo={props.archive.did || ""} newTab /> 645 + </div> 646 + </Show> 647 + </div> 648 + ); 649 + };
+15 -1
src/components/notification.tsx
··· 7 progress?: number; 8 total?: number; 9 type?: "info" | "success" | "error"; 10 }; 11 12 const [notifications, setNotifications] = createStore<Notification[]>([]); ··· 48 "animate-[slideIn_0.25s_ease-in]": !removingIds().has(notification.id), 49 "animate-[slideOut_0.25s_ease-in]": removingIds().has(notification.id), 50 }} 51 - onClick={() => removeNotification(notification.id)} 52 > 53 <div class="flex items-center gap-2 text-sm"> 54 <Show when={notification.progress !== undefined}> ··· 82 {notification.progress}% 83 </div> 84 </Show> 85 </div> 86 </Show> 87 </div>
··· 7 progress?: number; 8 total?: number; 9 type?: "info" | "success" | "error"; 10 + onCancel?: () => void; 11 }; 12 13 const [notifications, setNotifications] = createStore<Notification[]>([]); ··· 49 "animate-[slideIn_0.25s_ease-in]": !removingIds().has(notification.id), 50 "animate-[slideOut_0.25s_ease-in]": removingIds().has(notification.id), 51 }} 52 + onClick={() => 53 + notification.progress === undefined && removeNotification(notification.id) 54 + } 55 > 56 <div class="flex items-center gap-2 text-sm"> 57 <Show when={notification.progress !== undefined}> ··· 85 {notification.progress}% 86 </div> 87 </Show> 88 + <Show when={notification.onCancel}> 89 + <button 90 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 dark:bg-dark-300 mt-1 rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 91 + onClick={(e) => { 92 + e.stopPropagation(); 93 + notification.onCancel?.(); 94 + }} 95 + > 96 + Cancel 97 + </button> 98 + </Show> 99 </div> 100 </Show> 101 </div>
+44
src/views/car/index.tsx
···
··· 1 + import { Title } from "@solidjs/meta"; 2 + import { A } from "@solidjs/router"; 3 + 4 + export const CarView = () => { 5 + return ( 6 + <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 7 + <Title>Archive tools - PDSls</Title> 8 + <div class="flex flex-col gap-y-1"> 9 + <h1 class="text-lg font-semibold">Archive tools</h1> 10 + <p class="text-sm text-neutral-600 dark:text-neutral-400"> 11 + Tools for working with CAR (Content Addressable aRchive) files. 12 + </p> 13 + </div> 14 + 15 + <div class="flex flex-col gap-3"> 16 + <A 17 + href="explore" 18 + class="dark:bg-dark-300 flex items-start gap-3 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 text-left transition-colors hover:border-neutral-400 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:border-neutral-500 dark:hover:bg-neutral-800" 19 + > 20 + <span class="iconify lucide--folder-search mt-0.5 shrink-0 text-xl text-neutral-500 dark:text-neutral-400" /> 21 + <div class="flex flex-col gap-1"> 22 + <span class="font-medium">Explore archive</span> 23 + <span class="text-sm text-neutral-600 dark:text-neutral-400"> 24 + Browse records inside a repository archive 25 + </span> 26 + </div> 27 + </A> 28 + 29 + <A 30 + href="unpack" 31 + class="dark:bg-dark-300 flex items-start gap-3 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 text-left transition-colors hover:border-neutral-400 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:border-neutral-500 dark:hover:bg-neutral-800" 32 + > 33 + <span class="iconify lucide--file-archive mt-0.5 shrink-0 text-xl text-neutral-500 dark:text-neutral-400" /> 34 + <div class="flex flex-col gap-1"> 35 + <span class="font-medium">Unpack archive</span> 36 + <span class="text-sm text-neutral-600 dark:text-neutral-400"> 37 + Extract records from an archive into a ZIP file 38 + </span> 39 + </div> 40 + </A> 41 + </div> 42 + </div> 43 + ); 44 + };
+152
src/views/car/logger.tsx
···
··· 1 + import { For } from "solid-js"; 2 + import { createMutable } from "solid-js/store"; 3 + 4 + interface LogEntry { 5 + type: "log" | "info" | "warn" | "error"; 6 + at: number; 7 + msg: string; 8 + } 9 + 10 + interface PendingLogEntry { 11 + msg: string; 12 + } 13 + 14 + export const createLogger = () => { 15 + const pending = createMutable<PendingLogEntry[]>([]); 16 + 17 + let backlog: LogEntry[] | undefined = []; 18 + let push = (entry: LogEntry) => { 19 + backlog!.push(entry); 20 + }; 21 + 22 + return { 23 + internal: { 24 + get pending() { 25 + return pending; 26 + }, 27 + attach(fn: (entry: LogEntry) => void) { 28 + if (backlog !== undefined) { 29 + for (let idx = 0, len = backlog.length; idx < len; idx++) { 30 + fn(backlog[idx]); 31 + } 32 + backlog = undefined; 33 + } 34 + push = fn; 35 + }, 36 + }, 37 + log(msg: string) { 38 + push({ type: "log", at: Date.now(), msg }); 39 + }, 40 + info(msg: string) { 41 + push({ type: "info", at: Date.now(), msg }); 42 + }, 43 + warn(msg: string) { 44 + push({ type: "warn", at: Date.now(), msg }); 45 + }, 46 + error(msg: string) { 47 + push({ type: "error", at: Date.now(), msg }); 48 + }, 49 + progress(initialMsg: string, throttleMs = 500) { 50 + pending.unshift({ msg: initialMsg }); 51 + 52 + let entry: PendingLogEntry | undefined = pending[0]; 53 + 54 + return { 55 + update: throttle((msg: string) => { 56 + if (entry !== undefined) { 57 + entry.msg = msg; 58 + } 59 + }, throttleMs), 60 + destroy() { 61 + if (entry !== undefined) { 62 + const index = pending.indexOf(entry); 63 + pending.splice(index, 1); 64 + entry = undefined; 65 + } 66 + }, 67 + [Symbol.dispose]() { 68 + this.destroy(); 69 + }, 70 + }; 71 + }, 72 + }; 73 + }; 74 + 75 + export type Logger = ReturnType<typeof createLogger>; 76 + 77 + const formatter = new Intl.DateTimeFormat("en-US", { timeStyle: "short", hour12: false }); 78 + 79 + export const LoggerView = (props: { logger: Logger }) => { 80 + return ( 81 + <ul class="flex flex-col font-mono text-xs empty:hidden"> 82 + <For each={props.logger.internal.pending}> 83 + {(entry) => ( 84 + <li class="flex gap-2 px-4 py-1 whitespace-pre-wrap"> 85 + <span class="shrink-0 font-medium whitespace-pre-wrap text-neutral-400">-----</span> 86 + <span class="wrap-break-word">{entry.msg}</span> 87 + </li> 88 + )} 89 + </For> 90 + 91 + <div 92 + ref={(node) => { 93 + props.logger.internal.attach(({ type, at, msg }) => { 94 + let ecn = `flex gap-2 whitespace-pre-wrap px-4 py-1`; 95 + let tcn = `shrink-0 whitespace-pre-wrap font-medium`; 96 + if (type === "log") { 97 + tcn += ` text-neutral-500`; 98 + } else if (type === "info") { 99 + ecn += ` bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300`; 100 + tcn += ` text-blue-500`; 101 + } else if (type === "warn") { 102 + ecn += ` bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300`; 103 + tcn += ` text-amber-500`; 104 + } else if (type === "error") { 105 + ecn += ` bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300`; 106 + tcn += ` text-red-500`; 107 + } 108 + 109 + const item = ( 110 + <li class={ecn}> 111 + <span class={tcn}>{formatter.format(at)}</span> 112 + <span class="wrap-break-word">{msg}</span> 113 + </li> 114 + ); 115 + 116 + if (item instanceof Node) { 117 + node.after(item); 118 + } 119 + }); 120 + }} 121 + /> 122 + </ul> 123 + ); 124 + }; 125 + 126 + const throttle = <T extends (...args: any[]) => void>(func: T, wait: number) => { 127 + let timeout: ReturnType<typeof setTimeout> | null = null; 128 + let lastArgs: Parameters<T> | null = null; 129 + let lastCallTime = 0; 130 + 131 + const invoke = () => { 132 + func(...lastArgs!); 133 + lastCallTime = Date.now(); 134 + timeout = null; 135 + }; 136 + 137 + return (...args: Parameters<T>) => { 138 + const now = Date.now(); 139 + const timeSinceLastCall = now - lastCallTime; 140 + 141 + lastArgs = args; 142 + 143 + if (timeSinceLastCall >= wait) { 144 + if (timeout !== null) { 145 + clearTimeout(timeout); 146 + } 147 + invoke(); 148 + } else if (timeout === null) { 149 + timeout = setTimeout(invoke, wait - timeSinceLastCall); 150 + } 151 + }; 152 + };
+146
src/views/car/shared.tsx
···
··· 1 + import * as CBOR from "@atcute/cbor"; 2 + import * as CID from "@atcute/cid"; 3 + import { A } from "@solidjs/router"; 4 + import { Show } from "solid-js"; 5 + import { type JSONType } from "../../components/json.jsx"; 6 + 7 + export const isIOS = 8 + /iPad|iPhone|iPod/.test(navigator.userAgent) || 9 + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); 10 + 11 + // Convert CBOR-decoded objects to JSON-friendly format 12 + export const toJsonValue = (obj: unknown): JSONType => { 13 + if (obj === null || obj === undefined) return null; 14 + 15 + if (CID.isCidLink(obj)) { 16 + return { $link: obj.$link }; 17 + } 18 + 19 + if ( 20 + obj && 21 + typeof obj === "object" && 22 + "version" in obj && 23 + "codec" in obj && 24 + "digest" in obj && 25 + "bytes" in obj 26 + ) { 27 + try { 28 + return { $link: CID.toString(obj as CID.Cid) }; 29 + } catch {} 30 + } 31 + 32 + if (CBOR.isBytes(obj)) { 33 + return { $bytes: obj.$bytes }; 34 + } 35 + 36 + if (Array.isArray(obj)) { 37 + return obj.map(toJsonValue); 38 + } 39 + 40 + if (typeof obj === "object") { 41 + const result: Record<string, JSONType> = {}; 42 + for (const [key, value] of Object.entries(obj)) { 43 + result[key] = toJsonValue(value); 44 + } 45 + return result; 46 + } 47 + 48 + return obj as JSONType; 49 + }; 50 + 51 + export interface Archive { 52 + file: File; 53 + did: string; 54 + entries: CollectionEntry[]; 55 + } 56 + 57 + export interface CollectionEntry { 58 + name: string; 59 + entries: RecordEntry[]; 60 + } 61 + 62 + export interface RecordEntry { 63 + key: string; 64 + cid: string; 65 + record: JSONType; 66 + } 67 + 68 + export type View = 69 + | { type: "repo" } 70 + | { type: "collection"; collection: CollectionEntry } 71 + | { type: "record"; collection: CollectionEntry; record: RecordEntry }; 72 + 73 + export const WelcomeView = (props: { 74 + title: string; 75 + subtitle: string; 76 + loading: boolean; 77 + progress?: number; 78 + error?: string; 79 + onFileChange: (e: Event) => void; 80 + onDrop: (e: DragEvent) => void; 81 + onDragOver: (e: DragEvent) => void; 82 + }) => { 83 + return ( 84 + <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 85 + <div class="flex flex-col gap-y-1"> 86 + <div class="flex items-center gap-2 text-lg"> 87 + <A 88 + href="/car" 89 + class="flex size-7 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-200 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200" 90 + > 91 + <span class="iconify lucide--arrow-left" /> 92 + </A> 93 + <h1 class="font-semibold">{props.title}</h1> 94 + </div> 95 + <p class="text-sm text-neutral-600 dark:text-neutral-400">{props.subtitle}</p> 96 + </div> 97 + 98 + <div 99 + class="dark:bg-dark-300 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 p-8 transition-colors hover:border-neutral-400 dark:border-neutral-600 dark:hover:border-neutral-500" 100 + onDrop={props.onDrop} 101 + onDragOver={props.onDragOver} 102 + > 103 + <Show 104 + when={!props.loading} 105 + fallback={ 106 + <div class="flex flex-col items-center gap-2"> 107 + <span class="iconify lucide--loader-circle animate-spin text-3xl text-neutral-400" /> 108 + <span class="text-sm font-medium text-neutral-600 dark:text-neutral-400"> 109 + Reading CAR file... 110 + </span> 111 + <Show when={props.progress && props.progress > 0}> 112 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 113 + {props.progress?.toLocaleString()} records processed 114 + </span> 115 + </Show> 116 + </div> 117 + } 118 + > 119 + <span class="iconify lucide--folder-archive text-3xl text-neutral-400" /> 120 + <div class="text-center"> 121 + <p class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 122 + Drag and drop a CAR file here 123 + </p> 124 + <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 125 + </div> 126 + <label class="dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 flex items-center gap-1 rounded-md border border-neutral-300 bg-neutral-50 px-2.5 py-1.5 text-sm text-neutral-700 transition-colors select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:text-neutral-300"> 127 + <input 128 + type="file" 129 + accept={isIOS ? undefined : ".car,application/vnd.ipld.car"} 130 + onChange={props.onFileChange} 131 + class="hidden" 132 + /> 133 + <span class="iconify lucide--upload text-sm" /> 134 + Choose file 135 + </label> 136 + </Show> 137 + </div> 138 + 139 + <Show when={props.error}> 140 + <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"> 141 + {props.error} 142 + </div> 143 + </Show> 144 + </div> 145 + ); 146 + };
+249
src/views/car/unpack.tsx
···
··· 1 + import { fromStream } from "@atcute/repo"; 2 + import { zip, type ZipEntry } from "@mary/zip"; 3 + import { Title } from "@solidjs/meta"; 4 + import { FileSystemWritableFileStream, showSaveFilePicker } from "native-file-system-adapter"; 5 + import { createSignal, onCleanup } from "solid-js"; 6 + import { createDropHandler, createFileChangeHandler, handleDragOver } from "./file-handlers.js"; 7 + import { createLogger, LoggerView } from "./logger.jsx"; 8 + import { isIOS, toJsonValue, WelcomeView } from "./shared.jsx"; 9 + 10 + // Check if browser natively supports File System Access API 11 + const hasNativeFileSystemAccess = "showSaveFilePicker" in window; 12 + 13 + // HACK: Disable compression on WebKit due to an error being thrown 14 + const isWebKit = 15 + isIOS || (/AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)); 16 + 17 + const INVALID_CHAR_RE = /[<>:"/\\|?*\x00-\x1F]/g; 18 + const filenamify = (name: string) => { 19 + return name.replace(INVALID_CHAR_RE, "~"); 20 + }; 21 + 22 + export const UnpackToolView = () => { 23 + const logger = createLogger(); 24 + const [pending, setPending] = createSignal(false); 25 + 26 + let abortController: AbortController | undefined; 27 + 28 + onCleanup(() => { 29 + abortController?.abort(); 30 + }); 31 + 32 + const unpackToZip = async (file: File) => { 33 + abortController?.abort(); 34 + abortController = new AbortController(); 35 + const signal = abortController.signal; 36 + 37 + setPending(true); 38 + logger.log(`Starting extraction`); 39 + 40 + let repo: Awaited<ReturnType<typeof fromStream>> | undefined; 41 + 42 + const stream = file.stream(); 43 + repo = fromStream(stream); 44 + 45 + try { 46 + let count = 0; 47 + 48 + // On Safari/browsers without native File System Access API, use blob download 49 + if (!hasNativeFileSystemAccess) { 50 + const chunks: BlobPart[] = []; 51 + 52 + const entryGenerator = async function* (): AsyncGenerator<ZipEntry> { 53 + const progress = logger.progress(`Unpacking records (0 entries)`); 54 + 55 + try { 56 + for await (const entry of repo) { 57 + if (signal.aborted) return; 58 + 59 + try { 60 + const record = toJsonValue(entry.record); 61 + const filename = `${entry.collection}/${filenamify(entry.rkey)}.json`; 62 + const data = JSON.stringify(record, null, 2); 63 + 64 + yield { filename, data, compress: isWebKit ? false : "deflate" }; 65 + count++; 66 + progress.update(`Unpacking records (${count} entries)`); 67 + } catch { 68 + // Skip entries with invalid data 69 + } 70 + } 71 + } finally { 72 + progress[Symbol.dispose]?.(); 73 + } 74 + }; 75 + 76 + for await (const chunk of zip(entryGenerator())) { 77 + if (signal.aborted) return; 78 + chunks.push(chunk as BlobPart); 79 + } 80 + 81 + if (signal.aborted) return; 82 + 83 + logger.log(`${count} records extracted`); 84 + logger.log(`Creating download...`); 85 + 86 + const blob = new Blob(chunks, { type: "application/zip" }); 87 + const url = URL.createObjectURL(blob); 88 + const a = document.createElement("a"); 89 + a.href = url; 90 + a.download = `${file.name.replace(/\.car$/, "")}.zip`; 91 + document.body.appendChild(a); 92 + a.click(); 93 + document.body.removeChild(a); 94 + URL.revokeObjectURL(url); 95 + 96 + logger.log(`Finished! Download started.`); 97 + setPending(false); 98 + return; 99 + } 100 + 101 + // Native File System Access API path 102 + let writable: FileSystemWritableFileStream | undefined; 103 + 104 + // Create async generator that yields ZipEntry as we read from CAR 105 + const entryGenerator = async function* (): AsyncGenerator<ZipEntry> { 106 + const progress = logger.progress(`Unpacking records (0 entries)`); 107 + 108 + try { 109 + for await (const entry of repo) { 110 + if (signal.aborted) return; 111 + 112 + // Prompt for save location on first record 113 + if (writable === undefined) { 114 + const waiting = logger.progress(`Waiting for user...`); 115 + 116 + try { 117 + const fd = await showSaveFilePicker({ 118 + suggestedName: `${file.name.replace(/\.car$/, "")}.zip`, 119 + // @ts-expect-error: ponyfill doesn't have full typings 120 + id: "car-unpack", 121 + startIn: "downloads", 122 + types: [ 123 + { 124 + description: "ZIP archive", 125 + accept: { "application/zip": [".zip"] }, 126 + }, 127 + ], 128 + }).catch((err) => { 129 + if (err instanceof DOMException && err.name === "AbortError") { 130 + logger.warn(`File picker was cancelled`); 131 + } else { 132 + logger.warn(`Something went wrong when opening the file picker`); 133 + } 134 + return undefined; 135 + }); 136 + 137 + if (!fd) { 138 + logger.warn(`No file handle obtained`); 139 + return; 140 + } 141 + 142 + writable = await fd.createWritable(); 143 + 144 + if (writable === undefined) { 145 + logger.warn(`Failed to create writable stream`); 146 + return; 147 + } 148 + } finally { 149 + waiting[Symbol.dispose]?.(); 150 + } 151 + } 152 + 153 + try { 154 + const record = toJsonValue(entry.record); 155 + const filename = `${entry.collection}/${filenamify(entry.rkey)}.json`; 156 + const data = JSON.stringify(record, null, 2); 157 + 158 + yield { filename, data, compress: isWebKit ? false : "deflate" }; 159 + count++; 160 + progress.update(`Unpacking records (${count} entries)`); 161 + } catch { 162 + // Skip entries with invalid data 163 + } 164 + } 165 + } finally { 166 + progress[Symbol.dispose]?.(); 167 + } 168 + }; 169 + 170 + // Stream entries directly to zip, then to file 171 + let writeCount = 0; 172 + for await (const chunk of zip(entryGenerator())) { 173 + if (signal.aborted) { 174 + await writable?.abort(); 175 + return; 176 + } 177 + if (writable === undefined) { 178 + // User cancelled file picker 179 + setPending(false); 180 + return; 181 + } 182 + writeCount++; 183 + // Await every 100th write to apply backpressure 184 + if (writeCount % 100 === 0) { 185 + await writable.write(chunk); 186 + } else { 187 + writable.write(chunk); // Fire and forget 188 + } 189 + } 190 + 191 + if (signal.aborted) return; 192 + 193 + if (writable === undefined) { 194 + logger.warn(`CAR file has no records`); 195 + setPending(false); 196 + return; 197 + } 198 + 199 + logger.log(`${count} records extracted`); 200 + 201 + { 202 + const flushProgress = logger.progress(`Flushing writes...`); 203 + try { 204 + await writable.close(); 205 + logger.log(`Finished! File saved successfully.`); 206 + } catch (err) { 207 + logger.error(`Failed to save file: ${err}`); 208 + throw err; // Re-throw to be caught by outer catch 209 + } finally { 210 + flushProgress[Symbol.dispose]?.(); 211 + } 212 + } 213 + } catch (err) { 214 + if (signal.aborted) return; 215 + logger.error(`Error: ${err}\nFile might be malformed, or might not be a CAR archive`); 216 + } finally { 217 + await repo?.dispose(); 218 + if (!signal.aborted) { 219 + setPending(false); 220 + } 221 + } 222 + }; 223 + 224 + const handleFileChange = createFileChangeHandler(unpackToZip); 225 + 226 + // Wrap handleDrop to prevent multiple simultaneous uploads 227 + const baseDrop = createDropHandler(unpackToZip); 228 + const handleDrop = (e: DragEvent) => { 229 + if (pending()) return; 230 + baseDrop(e); 231 + }; 232 + 233 + return ( 234 + <> 235 + <Title>Unpack archive - PDSls</Title> 236 + <WelcomeView 237 + title="Unpack archive" 238 + subtitle="Upload a CAR file to extract all records into a ZIP archive." 239 + loading={pending()} 240 + onFileChange={handleFileChange} 241 + onDrop={handleDrop} 242 + onDragOver={handleDragOver} 243 + /> 244 + <div class="w-full max-w-3xl px-2"> 245 + <LoggerView logger={logger} /> 246 + </div> 247 + </> 248 + ); 249 + };
+24
src/views/car/file-handlers.ts
···
··· 1 + export const isCarFile = (file: File): boolean => { 2 + return file.name.endsWith(".car") || file.type === "application/vnd.ipld.car"; 3 + }; 4 + 5 + export const createFileChangeHandler = (onFile: (file: File) => void) => (e: Event) => { 6 + const input = e.target as HTMLInputElement; 7 + const file = input.files?.[0]; 8 + if (file) { 9 + onFile(file); 10 + } 11 + input.value = ""; 12 + }; 13 + 14 + export const createDropHandler = (onFile: (file: File) => void) => (e: DragEvent) => { 15 + e.preventDefault(); 16 + const file = e.dataTransfer?.files?.[0]; 17 + if (file && isCarFile(file)) { 18 + onFile(file); 19 + } 20 + }; 21 + 22 + export const handleDragOver = (e: DragEvent) => { 23 + e.preventDefault(); 24 + };
public/fonts/Figtree[wght].woff2 public/fonts/Figtree.woff2
+3
src/auth/state.ts
··· 12 13 export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 14 export const [sessions, setSessions] = createStore<Sessions>();
··· 12 13 export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 14 export const [sessions, setSessions] = createStore<Sessions>(); 15 + export const [openManager, setOpenManager] = createSignal(false); 16 + export const [showAddAccount, setShowAddAccount] = createSignal(false); 17 + export const [pendingPermissionEdit, setPendingPermissionEdit] = createSignal<string | null>(null);
+36
src/components/favicon.tsx
···
··· 1 + import { createSignal, JSX, Match, Show, Switch } from "solid-js"; 2 + 3 + export const Favicon = (props: { 4 + authority: string; 5 + wrapper?: (children: JSX.Element) => JSX.Element; 6 + }) => { 7 + const [loaded, setLoaded] = createSignal(false); 8 + const domain = () => props.authority.split(".").reverse().join("."); 9 + 10 + const content = ( 11 + <Switch> 12 + <Match when={domain() === "tangled.sh"}> 13 + <span class="iconify i-tangled size-4" /> 14 + </Match> 15 + <Match when={["bsky.app", "bsky.chat"].includes(domain())}> 16 + <img src="https://web-cdn.bsky.app/static/apple-touch-icon.png" class="size-4" /> 17 + </Match> 18 + <Match when={true}> 19 + <Show when={!loaded()}> 20 + <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" /> 21 + </Show> 22 + <img 23 + src={`https://${domain()}/favicon.ico`} 24 + class="size-4" 25 + classList={{ hidden: !loaded() }} 26 + onLoad={() => setLoaded(true)} 27 + onError={() => setLoaded(false)} 28 + /> 29 + </Match> 30 + </Switch> 31 + ); 32 + 33 + return props.wrapper ? 34 + props.wrapper(content) 35 + : <div class="flex h-5 w-4 shrink-0 items-center justify-center">{content}</div>; 36 + };
+8 -3
src/auth/oauth-config.ts
··· 1 - import { configureOAuth, defaultIdentityResolver } from "@atcute/oauth-browser-client"; 2 import { didDocumentResolver, handleResolver } from "../utils/api"; 3 4 configureOAuth({ 5 metadata: { 6 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 7 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 8 }, 9 - identityResolver: defaultIdentityResolver({ 10 handleResolver: handleResolver, 11 - didDocumentResolver: didDocumentResolver, 12 }), 13 });
··· 1 + import { LocalActorResolver } from "@atcute/identity-resolver"; 2 + import { configureOAuth } from "@atcute/oauth-browser-client"; 3 import { didDocumentResolver, handleResolver } from "../utils/api"; 4 5 + const reactiveDidDocumentResolver = { 6 + resolve: async (did: string) => didDocumentResolver().resolve(did as any), 7 + }; 8 + 9 configureOAuth({ 10 metadata: { 11 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 12 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 13 }, 14 + identityResolver: new LocalActorResolver({ 15 handleResolver: handleResolver, 16 + didDocumentResolver: reactiveDidDocumentResolver, 17 }), 18 });
+110 -15
src/utils/api.ts
··· 16 import { DohJsonLexiconAuthorityResolver, LexiconSchemaResolver } from "@atcute/lexicon-resolver"; 17 import { Did, Handle } from "@atcute/lexicons"; 18 import { AtprotoDid, isHandle, Nsid } from "@atcute/lexicons/syntax"; 19 import { createStore } from "solid-js/store"; 20 import { setPDS } from "../components/navbar"; 21 22 - export const didDocumentResolver = new CompositeDidDocumentResolver({ 23 - methods: { 24 - plc: new PlcDidDocumentResolver({ 25 - apiUrl: localStorage.getItem("plcDirectory") ?? "https://plc.directory", 26 }), 27 - web: new AtprotoWebDidDocumentResolver(), 28 - }, 29 - }); 30 31 export const handleResolver = new CompositeHandleResolver({ 32 strategy: "dns-first", ··· 40 dohUrl: "https://dns.google/resolve?", 41 }); 42 43 - const schemaResolver = new LexiconSchemaResolver({ 44 - didDocumentResolver: didDocumentResolver, 45 - }); 46 47 const didPDSCache: Record<string, string> = {}; 48 const [labelerCache, setLabelerCache] = createStore<Record<string, string>>({}); ··· 54 throw new Error("Not a valid DID identifier"); 55 } 56 57 - const doc = await didDocumentResolver.resolve(did); 58 - didDocCache[did] = doc; 59 60 const pds = getPdsEndpoint(doc); 61 - 62 63 64 ··· 83 if (!isAtprotoDid(did)) { 84 throw new Error("Not a valid DID identifier"); 85 } 86 - return await didDocumentResolver.resolve(did); 87 }; 88 89 const validateHandle = async (handle: Handle, did: Did) => { ··· 145 }; 146 147 const resolveLexiconSchema = async (authority: AtprotoDid, nsid: Nsid) => { 148 - return await schemaResolver.resolve(authority, nsid); 149 }; 150 151 interface LinkData {
··· 16 import { DohJsonLexiconAuthorityResolver, LexiconSchemaResolver } from "@atcute/lexicon-resolver"; 17 import { Did, Handle } from "@atcute/lexicons"; 18 import { AtprotoDid, isHandle, Nsid } from "@atcute/lexicons/syntax"; 19 + import { createMemo } from "solid-js"; 20 import { createStore } from "solid-js/store"; 21 import { setPDS } from "../components/navbar"; 22 + import { plcDirectory } from "../views/settings"; 23 24 + export const didDocumentResolver = createMemo( 25 + () => 26 + new CompositeDidDocumentResolver({ 27 + methods: { 28 + plc: new PlcDidDocumentResolver({ 29 + apiUrl: plcDirectory(), 30 + }), 31 + web: new AtprotoWebDidDocumentResolver(), 32 + }, 33 }), 34 + ); 35 36 export const handleResolver = new CompositeHandleResolver({ 37 strategy: "dns-first", ··· 45 dohUrl: "https://dns.google/resolve?", 46 }); 47 48 + const schemaResolver = createMemo( 49 + () => 50 + new LexiconSchemaResolver({ 51 + didDocumentResolver: didDocumentResolver(), 52 + }), 53 + ); 54 55 const didPDSCache: Record<string, string> = {}; 56 const [labelerCache, setLabelerCache] = createStore<Record<string, string>>({}); ··· 62 throw new Error("Not a valid DID identifier"); 63 } 64 65 + let doc: DidDocument; 66 + try { 67 + doc = await didDocumentResolver().resolve(did); 68 + didDocCache[did] = doc; 69 + } catch (e) { 70 + console.error(e); 71 + throw new Error("Error during did document resolution"); 72 + } 73 74 const pds = getPdsEndpoint(doc); 75 + const labeler = getLabelerEndpoint(doc); 76 77 78 ··· 97 if (!isAtprotoDid(did)) { 98 throw new Error("Not a valid DID identifier"); 99 } 100 + return await didDocumentResolver().resolve(did); 101 }; 102 103 const validateHandle = async (handle: Handle, did: Did) => { ··· 159 }; 160 161 const resolveLexiconSchema = async (authority: AtprotoDid, nsid: Nsid) => { 162 + return await schemaResolver().resolve(authority, nsid); 163 }; 164 165 interface LinkData { 166 + 167 + 168 + 169 + 170 + 171 + 172 + 173 + 174 + 175 + 176 + 177 + 178 + 179 + 180 + 181 + 182 + 183 + 184 + 185 + 186 + 187 + 188 + 189 + 190 + 191 + 192 + 193 + 194 + 195 + 196 + 197 + 198 + 199 + 200 + 201 + 202 + 203 + 204 + 205 + 206 + 207 + 208 + 209 + 210 + 211 + 212 + 213 + 214 + 215 + ): Promise<LinksWithRecords> => 216 + getConstellation("/links", target, collection, path, cursor, limit || 100); 217 + 218 + export interface HandleResolveResult { 219 + success: boolean; 220 + did?: string; 221 + error?: string; 222 + } 223 + 224 + export const resolveHandleDetailed = async (handle: Handle) => { 225 + const dnsResolver = new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?" }); 226 + const httpResolver = new WellKnownHandleResolver(); 227 + 228 + const tryResolve = async ( 229 + resolver: DohJsonHandleResolver | WellKnownHandleResolver, 230 + ): Promise<HandleResolveResult> => { 231 + try { 232 + const did = await resolver.resolve(handle); 233 + return { success: true, did }; 234 + } catch (err: any) { 235 + return { success: false, error: err.message ?? String(err) }; 236 + } 237 + }; 238 + 239 + const [dns, http] = await Promise.all([tryResolve(dnsResolver), tryResolve(httpResolver)]); 240 + 241 + return { dns, http }; 242 + }; 243 + 244 + export { 245 + didDocCache, 246 + getAllBacklinks,
+1 -1
LICENSE
··· 1 - Copyright (c) 2024-2025 Juliet Philippe <m@juli.ee> 2 3 Permission to use, copy, modify, and/or distribute this software for any 4 purpose with or without fee is hereby granted.
··· 1 + Copyright (c) 2024-2026 Juliet Philippe <m@juli.ee> 2 3 Permission to use, copy, modify, and/or distribute this software for any 4 purpose with or without fee is hereby granted.
+2 -1
README.md
··· 1 - # PDSls - AT Protocol Explorer 2 3 Lightweight and client-side web app to navigate [atproto](https://atproto.com/). 4 ··· 9 - Jetstream and firehose (com.atproto.sync.subscribeRepos) streaming. 10 - Backlinks support with [constellation](https://constellation.microcosm.blue/). 11 - Query moderation labels. 12 13 ## Hacking 14
··· 1 + # PDSls - Atmosphere Explorer 2 3 Lightweight and client-side web app to navigate [atproto](https://atproto.com/). 4 ··· 9 - Jetstream and firehose (com.atproto.sync.subscribeRepos) streaming. 10 - Backlinks support with [constellation](https://constellation.microcosm.blue/). 11 - Query moderation labels. 12 + - Explore and unpack repository archives (CAR). 13 14 ## Hacking 15
+9
src/utils/format.ts
···
··· 1 + const formatFileSize = (bytes: number): string => { 2 + if (bytes === 0) return "0 B"; 3 + const k = 1024; 4 + const sizes = ["B", "KB", "MB", "GB"]; 5 + const i = Math.floor(Math.log(bytes) / Math.log(k)); 6 + return `${(bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1)} ${sizes[i]}`; 7 + }; 8 + 9 + export { formatFileSize };
+130
src/views/stream/stats.tsx
···
··· 1 + import { For, Show } from "solid-js"; 2 + import { STREAM_CONFIGS, StreamType } from "./config"; 3 + 4 + export type StreamStats = { 5 + connectedAt?: number; 6 + totalEvents: number; 7 + eventsPerSecond: number; 8 + eventTypes: Record<string, number>; 9 + collections: Record<string, number>; 10 + }; 11 + 12 + const formatUptime = (ms: number) => { 13 + const seconds = Math.floor(ms / 1000); 14 + const minutes = Math.floor(seconds / 60); 15 + const hours = Math.floor(minutes / 60); 16 + 17 + if (hours > 0) { 18 + return `${hours}h ${minutes % 60}m ${seconds % 60}s`; 19 + } else if (minutes > 0) { 20 + return `${minutes}m ${seconds % 60}s`; 21 + } else { 22 + return `${seconds}s`; 23 + } 24 + }; 25 + 26 + export const StreamStatsPanel = (props: { 27 + stats: StreamStats; 28 + currentTime: number; 29 + streamType: StreamType; 30 + showAllEvents?: boolean; 31 + }) => { 32 + const config = () => STREAM_CONFIGS[props.streamType]; 33 + const uptime = () => (props.stats.connectedAt ? props.currentTime - props.stats.connectedAt : 0); 34 + 35 + const shouldShowEventTypes = () => { 36 + if (!config().showEventTypes) return false; 37 + if (props.streamType === "jetstream") return props.showAllEvents === true; 38 + return true; 39 + }; 40 + 41 + const topCollections = () => 42 + Object.entries(props.stats.collections) 43 + .sort(([, a], [, b]) => b - a) 44 + .slice(0, 5); 45 + 46 + const topEventTypes = () => 47 + Object.entries(props.stats.eventTypes) 48 + .sort(([, a], [, b]) => b - a) 49 + .slice(0, 5); 50 + 51 + return ( 52 + <Show when={props.stats.connectedAt !== undefined}> 53 + <div class="w-full text-sm"> 54 + <div class="mb-1 font-semibold">Statistics</div> 55 + <div class="flex flex-wrap justify-between gap-x-4 gap-y-2"> 56 + <div> 57 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Uptime</div> 58 + <div class="font-mono">{formatUptime(uptime())}</div> 59 + </div> 60 + <div> 61 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Total Events</div> 62 + <div class="font-mono">{props.stats.totalEvents.toLocaleString()}</div> 63 + </div> 64 + <div> 65 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Events/sec</div> 66 + <div class="font-mono">{props.stats.eventsPerSecond.toFixed(1)}</div> 67 + </div> 68 + <div> 69 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Avg/sec</div> 70 + <div class="font-mono"> 71 + {uptime() > 0 ? ((props.stats.totalEvents / uptime()) * 1000).toFixed(1) : "0.0"} 72 + </div> 73 + </div> 74 + </div> 75 + 76 + <Show when={topEventTypes().length > 0 && shouldShowEventTypes()}> 77 + <div class="mt-2"> 78 + <div class="mb-1 text-xs text-neutral-500 dark:text-neutral-400">Event Types</div> 79 + <div class="grid grid-cols-[1fr_5rem_3rem] gap-x-1 gap-y-0.5 font-mono text-xs sm:gap-x-4"> 80 + <For each={topEventTypes()}> 81 + {([type, count]) => { 82 + const percentage = ((count / props.stats.totalEvents) * 100).toFixed(1); 83 + return ( 84 + <> 85 + <span class="text-neutral-700 dark:text-neutral-300">{type}</span> 86 + <span class="text-right text-neutral-600 tabular-nums dark:text-neutral-400"> 87 + {count.toLocaleString()} 88 + </span> 89 + <span class="text-right text-neutral-400 tabular-nums dark:text-neutral-500"> 90 + {percentage}% 91 + </span> 92 + </> 93 + ); 94 + }} 95 + </For> 96 + </div> 97 + </div> 98 + </Show> 99 + 100 + <Show when={topCollections().length > 0}> 101 + <div class="mt-2"> 102 + <div class="mb-1 text-xs text-neutral-500 dark:text-neutral-400"> 103 + {config().collectionsLabel} 104 + </div> 105 + <div class="grid grid-cols-[1fr_5rem_3rem] gap-x-1 gap-y-0.5 font-mono text-xs sm:gap-x-4"> 106 + <For each={topCollections()}> 107 + {([collection, count]) => { 108 + const percentage = ((count / props.stats.totalEvents) * 100).toFixed(1); 109 + return ( 110 + <> 111 + <span class="min-w-0 truncate text-neutral-700 dark:text-neutral-300"> 112 + {collection} 113 + </span> 114 + <span class="text-right text-neutral-600 tabular-nums dark:text-neutral-400"> 115 + {count.toLocaleString()} 116 + </span> 117 + <span class="text-right text-neutral-400 tabular-nums dark:text-neutral-500"> 118 + {percentage}% 119 + </span> 120 + </> 121 + ); 122 + }} 123 + </For> 124 + </div> 125 + </div> 126 + </Show> 127 + </div> 128 + </Show> 129 + ); 130 + };
+130
src/components/hover-card/base.tsx
···
··· 1 + import { A } from "@solidjs/router"; 2 + import { createSignal, JSX, onCleanup, Show } from "solid-js"; 3 + import { Portal } from "solid-js/web"; 4 + import { canHover } from "../../layout"; 5 + 6 + interface HoverCardProps { 7 + /** Link href - if provided, renders an A tag */ 8 + href?: string; 9 + /** Link/trigger label text */ 10 + label?: string; 11 + /** Open link in new tab */ 12 + newTab?: boolean; 13 + /** Called when hover starts (for prefetching) */ 14 + onHover?: () => void; 15 + /** Delay in ms before showing card and calling onHover (default: 0) */ 16 + hoverDelay?: number; 17 + /** Custom trigger element - if provided, overrides href/label */ 18 + trigger?: JSX.Element; 19 + /** Additional classes for the wrapper span */ 20 + class?: string; 21 + /** Additional classes for the link/label */ 22 + labelClass?: string; 23 + /** Additional classes for the preview container */ 24 + previewClass?: string; 25 + /** Preview content */ 26 + children: JSX.Element; 27 + } 28 + 29 + const HoverCard = (props: HoverCardProps) => { 30 + const [show, setShow] = createSignal(false); 31 + 32 + const [previewHeight, setPreviewHeight] = createSignal(0); 33 + const [anchorRect, setAnchorRect] = createSignal<DOMRect | null>(null); 34 + let anchorRef!: HTMLSpanElement; 35 + let previewRef!: HTMLDivElement; 36 + let resizeObserver: ResizeObserver | null = null; 37 + let hoverTimeout: number | null = null; 38 + 39 + const setupResizeObserver = (el: HTMLDivElement) => { 40 + resizeObserver?.disconnect(); 41 + previewRef = el; 42 + resizeObserver = new ResizeObserver(() => { 43 + if (previewRef) setPreviewHeight(previewRef.offsetHeight); 44 + }); 45 + resizeObserver.observe(el); 46 + }; 47 + 48 + onCleanup(() => { 49 + resizeObserver?.disconnect(); 50 + if (hoverTimeout !== null) { 51 + clearTimeout(hoverTimeout); 52 + } 53 + }); 54 + 55 + const isOverflowing = (previewHeight: number) => { 56 + const rect = anchorRect(); 57 + return rect && rect.top + previewHeight + 32 > window.innerHeight; 58 + }; 59 + 60 + const getPreviewStyle = () => { 61 + const rect = anchorRect(); 62 + if (!rect) return {}; 63 + 64 + const left = rect.left + rect.width / 2; 65 + const overflowing = isOverflowing(previewHeight()); 66 + const gap = 4; 67 + 68 + return { 69 + left: `${left}px`, 70 + top: overflowing ? `${rect.top - gap}px` : `${rect.bottom + gap}px`, 71 + transform: overflowing ? "translate(-50%, -100%)" : "translate(-50%, 0)", 72 + }; 73 + }; 74 + 75 + const handleMouseEnter = () => { 76 + const delay = props.hoverDelay ?? 0; 77 + setAnchorRect(anchorRef.getBoundingClientRect()); 78 + 79 + if (delay > 0) { 80 + hoverTimeout = window.setTimeout(() => { 81 + props.onHover?.(); 82 + setShow(true); 83 + hoverTimeout = null; 84 + }, delay); 85 + } else { 86 + props.onHover?.(); 87 + setShow(true); 88 + } 89 + }; 90 + 91 + const handleMouseLeave = () => { 92 + if (hoverTimeout !== null) { 93 + clearTimeout(hoverTimeout); 94 + hoverTimeout = null; 95 + } 96 + setShow(false); 97 + }; 98 + 99 + return ( 100 + <span 101 + ref={anchorRef} 102 + class={`group/hover-card relative ${props.class || "inline"}`} 103 + onMouseEnter={handleMouseEnter} 104 + onMouseLeave={handleMouseLeave} 105 + > 106 + {props.trigger ?? ( 107 + <A 108 + class={`text-blue-500 hover:underline active:underline dark:text-blue-400 ${props.labelClass || ""}`} 109 + href={props.href!} 110 + target={props.newTab ? "_blank" : undefined} 111 + > 112 + {props.label} 113 + </A> 114 + )} 115 + <Show when={show() && canHover}> 116 + <Portal> 117 + <div 118 + ref={setupResizeObserver} 119 + style={getPreviewStyle()} 120 + class={`dark:bg-dark-300 dark:shadow-dark-700 pointer-events-none fixed z-50 block overflow-hidden rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 shadow-md dark:border-neutral-700 ${props.previewClass ?? "max-h-80 w-max max-w-sm font-mono text-xs whitespace-pre-wrap sm:max-h-112 lg:max-w-lg"}`} 121 + > 122 + {props.children} 123 + </div> 124 + </Portal> 125 + </Show> 126 + </span> 127 + ); 128 + }; 129 + 130 + export default HoverCard;
+108
src/components/hover-card/did.tsx
···
··· 1 + import { getPdsEndpoint, type DidDocument } from "@atcute/identity"; 2 + import { createSignal, Show } from "solid-js"; 3 + import { resolveDidDoc } from "../../utils/api"; 4 + import HoverCard from "./base"; 5 + 6 + interface DidHoverCardProps { 7 + did: string; 8 + newTab?: boolean; 9 + class?: string; 10 + labelClass?: string; 11 + trigger?: any; 12 + hoverDelay?: number; 13 + } 14 + 15 + interface DidInfo { 16 + handle?: string; 17 + pds?: string; 18 + loading: boolean; 19 + error?: string; 20 + } 21 + 22 + const didCache = new Map<string, DidInfo>(); 23 + 24 + const prefetchDid = async (did: string) => { 25 + if (didCache.has(did)) return; 26 + 27 + didCache.set(did, { loading: true }); 28 + 29 + try { 30 + const doc: DidDocument = await resolveDidDoc(did as `did:${string}:${string}`); 31 + 32 + const handle = doc.alsoKnownAs?.find((aka) => aka.startsWith("at://"))?.replace("at://", ""); 33 + 34 + const pds = getPdsEndpoint(doc)?.replace("https://", "").replace("http://", ""); 35 + 36 + didCache.set(did, { handle, pds, loading: false }); 37 + } catch (err: any) { 38 + didCache.set(did, { loading: false, error: err.message || "Failed to resolve" }); 39 + } 40 + }; 41 + 42 + const DidHoverCard = (props: DidHoverCardProps) => { 43 + const [didInfo, setDidInfo] = createSignal<DidInfo | null>(null); 44 + 45 + const handlePrefetch = () => { 46 + prefetchDid(props.did); 47 + 48 + const cached = didCache.get(props.did); 49 + setDidInfo(cached || { loading: true }); 50 + 51 + if (!cached || cached.loading) { 52 + const pollInterval = setInterval(() => { 53 + const updated = didCache.get(props.did); 54 + if (updated && !updated.loading) { 55 + setDidInfo(updated); 56 + clearInterval(pollInterval); 57 + } 58 + }, 100); 59 + 60 + setTimeout(() => clearInterval(pollInterval), 10000); 61 + } 62 + }; 63 + 64 + return ( 65 + <HoverCard 66 + href={`/at://${props.did}`} 67 + label={props.did} 68 + newTab={props.newTab} 69 + onHover={handlePrefetch} 70 + hoverDelay={props.hoverDelay ?? 300} 71 + trigger={props.trigger} 72 + class={props.class} 73 + labelClass={props.labelClass} 74 + previewClass="w-max max-w-xs font-sans text-sm" 75 + > 76 + <Show when={didInfo()?.loading}> 77 + <div class="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400"> 78 + <span class="iconify lucide--loader-circle animate-spin" /> 79 + Loading... 80 + </div> 81 + </Show> 82 + <Show when={didInfo()?.error}> 83 + <div class="text-sm text-red-500 dark:text-red-400">{didInfo()?.error}</div> 84 + </Show> 85 + <Show when={!didInfo()?.loading && !didInfo()?.error}> 86 + <div class="flex flex-col gap-1"> 87 + <Show when={didInfo()?.handle}> 88 + <div class="flex items-center gap-2"> 89 + <span class="iconify lucide--at-sign text-neutral-500 dark:text-neutral-400" /> 90 + <span>{didInfo()?.handle}</span> 91 + </div> 92 + </Show> 93 + <Show when={didInfo()?.pds}> 94 + <div class="flex items-center gap-2"> 95 + <span class="iconify lucide--hard-drive text-neutral-500 dark:text-neutral-400" /> 96 + <span>{didInfo()?.pds}</span> 97 + </div> 98 + </Show> 99 + <Show when={!didInfo()?.handle && !didInfo()?.pds}> 100 + <div class="text-neutral-500 dark:text-neutral-400">No info available</div> 101 + </Show> 102 + </div> 103 + </Show> 104 + </HoverCard> 105 + ); 106 + }; 107 + 108 + export default DidHoverCard;
+119
src/components/hover-card/record.tsx
···
··· 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 + import { ActorIdentifier } from "@atcute/lexicons"; 3 + import { createSignal, Show } from "solid-js"; 4 + import { getPDS } from "../../utils/api"; 5 + import { JSONValue } from "../json"; 6 + import HoverCard from "./base"; 7 + 8 + interface RecordHoverCardProps { 9 + uri: string; 10 + newTab?: boolean; 11 + class?: string; 12 + labelClass?: string; 13 + trigger?: any; 14 + hoverDelay?: number; 15 + } 16 + 17 + const recordCache = new Map<string, { value: unknown; loading: boolean; error?: string }>(); 18 + 19 + const parseAtUri = (uri: string) => { 20 + const match = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 21 + if (!match) return null; 22 + return { repo: match[1], collection: match[2], rkey: match[3] }; 23 + }; 24 + 25 + const prefetchRecord = async (uri: string) => { 26 + if (recordCache.has(uri)) return; 27 + 28 + const parsed = parseAtUri(uri); 29 + if (!parsed) return; 30 + 31 + recordCache.set(uri, { value: null, loading: true }); 32 + 33 + try { 34 + const pds = await getPDS(parsed.repo); 35 + const rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 36 + const res = await rpc.get("com.atproto.repo.getRecord", { 37 + params: { 38 + repo: parsed.repo as ActorIdentifier, 39 + collection: parsed.collection as `${string}.${string}.${string}`, 40 + rkey: parsed.rkey, 41 + }, 42 + }); 43 + 44 + if (!res.ok) { 45 + recordCache.set(uri, { value: null, loading: false, error: res.data.error }); 46 + return; 47 + } 48 + 49 + recordCache.set(uri, { value: res.data.value, loading: false }); 50 + } catch (err: any) { 51 + recordCache.set(uri, { value: null, loading: false, error: err.message || "Failed to fetch" }); 52 + } 53 + }; 54 + 55 + const RecordHoverCard = (props: RecordHoverCardProps) => { 56 + const [record, setRecord] = createSignal<{ 57 + value: unknown; 58 + loading: boolean; 59 + error?: string; 60 + } | null>(null); 61 + 62 + const parsed = () => parseAtUri(props.uri); 63 + 64 + const handlePrefetch = () => { 65 + prefetchRecord(props.uri); 66 + 67 + // Start polling for cache updates 68 + const cached = recordCache.get(props.uri); 69 + setRecord(cached || { value: null, loading: true }); 70 + 71 + if (!cached || cached.loading) { 72 + const pollInterval = setInterval(() => { 73 + const updated = recordCache.get(props.uri); 74 + if (updated && !updated.loading) { 75 + setRecord(updated); 76 + clearInterval(pollInterval); 77 + } 78 + }, 100); 79 + 80 + setTimeout(() => clearInterval(pollInterval), 10000); 81 + } 82 + }; 83 + 84 + return ( 85 + <HoverCard 86 + href={`/${props.uri}`} 87 + label={props.uri} 88 + newTab={props.newTab} 89 + onHover={handlePrefetch} 90 + hoverDelay={props.hoverDelay ?? 300} 91 + trigger={props.trigger} 92 + class={props.class} 93 + labelClass={props.labelClass} 94 + > 95 + <Show when={record()?.loading}> 96 + <div class="flex items-center gap-2 font-sans text-sm text-neutral-500 dark:text-neutral-400"> 97 + <span class="iconify lucide--loader-circle animate-spin" /> 98 + Loading... 99 + </div> 100 + </Show> 101 + <Show when={record()?.error}> 102 + <div class="font-sans text-sm text-red-500 dark:text-red-400">{record()?.error}</div> 103 + </Show> 104 + <Show when={record()?.value && !record()?.loading}> 105 + <div class="font-mono text-xs wrap-break-word"> 106 + <JSONValue 107 + data={record()?.value as any} 108 + repo={parsed()?.repo || ""} 109 + truncate 110 + newTab 111 + hideBlobs 112 + /> 113 + </div> 114 + </Show> 115 + </HoverCard> 116 + ); 117 + }; 118 + 119 + export default RecordHoverCard;
+26 -18
src/views/labels.tsx
··· 197 198 199 200 201 202 ··· 262 263 264 265 - 266 - 267 - 268 - 269 - 270 - 271 - 272 - 273 - 274 - 275 - 276 - 277 - 278 - 279 - 280 - 281 - 282 283 284 285 </div> 286 </StickyOverlay> 287 288 - <div class="w-full max-w-3xl px-3 py-2"> 289 <Show when={loading() && labels().length === 0}> 290 <div class="flex flex-col items-center justify-center py-12 text-center"> 291 <span class="iconify lucide--loader-circle mb-3 animate-spin text-4xl text-neutral-400" />
··· 197 198 199 200 + <> 201 + <Title>Labels - PDSls</Title> 202 + <div class="flex w-full flex-col items-center"> 203 + <div class="flex w-full flex-col gap-y-1 px-3 pb-3"> 204 + <h1 class="text-lg font-semibold">Labels</h1> 205 + <p class="text-sm text-neutral-600 dark:text-neutral-400"> 206 + Query labels applied by labelers to accounts and records. 207 + </p> 208 + </div> 209 + <form 210 + ref={formRef} 211 + class="flex w-full max-w-3xl flex-col gap-y-3 px-3 pb-2" 212 213 214 ··· 274 275 276 277 + <Button 278 + onClick={handleLoadMore} 279 + disabled={loading()} 280 + classList={{ "w-20 justify-center": true }} 281 + > 282 + <Show 283 + when={!loading()} 284 + fallback={ 285 + <span class="iconify lucide--loader-circle animate-spin text-base" /> 286 + } 287 + > 288 + Load more 289 + </Show> 290 291 292 293 </div> 294 </StickyOverlay> 295 296 + <div class="w-full max-w-3xl py-2"> 297 <Show when={loading() && labels().length === 0}> 298 <div class="flex flex-col items-center justify-center py-12 text-center"> 299 <span class="iconify lucide--loader-circle mb-3 animate-spin text-4xl text-neutral-400" />
+7 -8
src/views/car/explore.tsx
··· 528 529 530 531 - 532 - 533 - 534 - 535 - 536 - 537 - 538 539 540 ··· 544 </Show> 545 </button> 546 } 547 - previewClass="max-h-80 w-max max-w-sm text-xs whitespace-pre-wrap sm:max-h-112 lg:max-w-lg" 548 > 549 <JSONValue data={entry.record} repo={props.archive.did} truncate hideBlobs /> 550 </HoverCard>
··· 528 529 530 531 + }} 532 + class="flex w-full items-baseline gap-1 text-left" 533 + > 534 + <span class="max-w-full shrink-0 truncate text-sm text-blue-500 dark:text-blue-400"> 535 + {entry.key} 536 + </span> 537 + <span class="truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 538 539 540 ··· 544 </Show> 545 </button> 546 } 547 > 548 <JSONValue data={entry.record} repo={props.archive.did} truncate hideBlobs /> 549 </HoverCard>
+38
src/components/permission-button.tsx
···
··· 1 + import { JSX } from "solid-js"; 2 + import { hasUserScope } from "../auth/scope-utils"; 3 + import { showPermissionPrompt } from "./permission-prompt"; 4 + import Tooltip from "./tooltip"; 5 + 6 + export interface PermissionButtonProps { 7 + scope: "create" | "update" | "delete" | "blob"; 8 + tooltip: string; 9 + class?: string; 10 + disabledClass?: string; 11 + onClick: () => void; 12 + children: JSX.Element; 13 + } 14 + 15 + export const PermissionButton = (props: PermissionButtonProps) => { 16 + const hasPermission = () => hasUserScope(props.scope); 17 + 18 + const handleClick = () => { 19 + if (hasPermission()) { 20 + props.onClick(); 21 + } else { 22 + showPermissionPrompt(props.scope); 23 + } 24 + }; 25 + 26 + const baseClass = 27 + props.class || 28 + "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"; 29 + const disabledClass = props.disabledClass || "flex items-center rounded-sm p-1.5 opacity-40"; 30 + 31 + return ( 32 + <Tooltip text={hasPermission() ? props.tooltip : `${props.tooltip} (permission required)`}> 33 + <button class={hasPermission() ? baseClass : disabledClass} onclick={handleClick}> 34 + {props.children} 35 + </button> 36 + </Tooltip> 37 + ); 38 + };
+52
src/components/permission-prompt.tsx
···
··· 1 + import { createSignal } from "solid-js"; 2 + import { GRANULAR_SCOPES } from "../auth/scope-utils"; 3 + import { agent, setOpenManager, setPendingPermissionEdit } from "../auth/state"; 4 + import { Button } from "./button"; 5 + import { Modal } from "./modal"; 6 + 7 + type ScopeId = "create" | "update" | "delete" | "blob"; 8 + 9 + const [requestedScope, setRequestedScope] = createSignal<ScopeId | null>(null); 10 + 11 + export const showPermissionPrompt = (scope: ScopeId) => { 12 + setRequestedScope(scope); 13 + }; 14 + 15 + export const PermissionPromptContainer = () => { 16 + const scopeLabel = () => { 17 + const scope = GRANULAR_SCOPES.find((s) => s.id === requestedScope()); 18 + return scope?.label.toLowerCase() || requestedScope(); 19 + }; 20 + 21 + const handleEditPermissions = () => { 22 + setRequestedScope(null); 23 + if (agent()) { 24 + setPendingPermissionEdit(agent()!.sub); 25 + setOpenManager(true); 26 + } 27 + }; 28 + 29 + return ( 30 + <Modal 31 + open={requestedScope() !== null} 32 + onClose={() => setRequestedScope(null)} 33 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[calc(100%-2rem)] max-w-md rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 34 + > 35 + <h2 class="mb-2 font-semibold">Permission required</h2> 36 + <p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400"> 37 + You need the "{scopeLabel()}" permission to perform this action. 38 + </p> 39 + <div class="flex justify-end gap-2"> 40 + <Button onClick={() => setRequestedScope(null)}>Cancel</Button> 41 + <Button 42 + onClick={handleEditPermissions} 43 + classList={{ 44 + "bg-blue-500! text-white! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400! border-none!": true, 45 + }} 46 + > 47 + Edit permissions 48 + </Button> 49 + </div> 50 + </Modal> 51 + ); 52 + };
+233 -11
src/components/create/index.tsx
··· 92 93 94 95 96 97 ··· 225 226 227 228 229 230 ··· 232 233 234 235 - onClose={() => setOpenDialog(false)} 236 - closeOnClick={false} 237 - nonBlocking={isMinimized()} 238 - > 239 - <div 240 - style="transform: translateX(-50%) translateZ(0);" 241 - classList={{ 242 - "dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto absolute top-18 left-1/2 flex flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-all duration-200 dark:border-neutral-700 starting:opacity-0": true, 243 - "w-[calc(100%-1rem)] max-w-3xl h-[65vh]": !isMaximized(), 244 - "w-[calc(100%-1rem)] max-w-7xl h-[85vh]": isMaximized(), 245 - hidden: isMinimized(),
··· 92 93 94 95 + embed: { 96 + $type: "app.bsky.embed.external", 97 + external: { 98 + uri: "https://pds.ls", 99 + title: "PDSls", 100 + description: "Browse the public data on atproto", 101 + }, 102 103 104 ··· 232 233 234 235 + onClose={() => setOpenDialog(false)} 236 + closeOnClick={false} 237 + nonBlocking={isMinimized()} 238 + alignTop 239 + contentClass={`dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto flex flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700 ${isMaximized() ? "w-[calc(100%-1rem)] max-w-7xl h-[85vh]" : "w-[calc(100%-1rem)] max-w-3xl h-[65vh]"} ${isMinimized() ? "hidden" : ""}`} 240 + > 241 + <div class="mb-2 flex w-full justify-between text-base"> 242 + <div class="flex items-center gap-2"> 243 + <span class="font-semibold select-none"> 244 + {props.create ? "Creating" : "Editing"} record 245 + </span> 246 + </div> 247 + <div class="flex items-center gap-1"> 248 + <button 249 + type="button" 250 + onclick={() => setIsMinimized(true)} 251 + class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 252 + > 253 + <span class="iconify lucide--minus"></span> 254 + </button> 255 + <button 256 + type="button" 257 + onclick={() => setIsMaximized(!isMaximized())} 258 + class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 259 + > 260 + <span 261 + class={`iconify ${isMaximized() ? "lucide--minimize-2" : "lucide--maximize-2"}`} 262 + ></span> 263 + </button> 264 + <button 265 + id="close" 266 + onclick={() => setOpenDialog(false)} 267 + class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 268 + > 269 + <span class="iconify lucide--x"></span> 270 + </button> 271 + </div> 272 + </div> 273 + <form ref={formRef} class="flex min-h-0 flex-1 flex-col gap-y-2"> 274 + <Show when={props.create}> 275 + <div class="flex flex-wrap items-center gap-1 text-sm"> 276 + <span>at://</span> 277 + <select 278 + class="dark:bg-dark-100 max-w-40 truncate rounded-md border border-neutral-200 bg-white px-1 py-1 select-none focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400" 279 + name="repo" 280 + id="repo" 281 + > 282 + <For each={Object.keys(sessions)}> 283 + {(session) => ( 284 + <option value={session} selected={session === agent()?.sub}> 285 + {sessions[session].handle ?? session} 286 + </option> 287 + )} 288 + </For> 289 + </select> 290 + <span>/</span> 291 + <TextInput 292 + id="collection" 293 + name="collection" 294 + placeholder="Collection (default: $type)" 295 + class={`w-40 placeholder:text-xs lg:w-52 ${collectionError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`} 296 + onInput={(e) => { 297 + const value = e.currentTarget.value; 298 + if (!value || isNsid(value)) setCollectionError(""); 299 + else 300 + setCollectionError( 301 + "Invalid collection: use reverse domain format (e.g. app.bsky.feed.post)", 302 + ); 303 + }} 304 + /> 305 + <span>/</span> 306 + <TextInput 307 + id="rkey" 308 + name="rkey" 309 + placeholder="Record key (default: TID)" 310 + class={`w-40 placeholder:text-xs lg:w-52 ${rkeyError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`} 311 + onInput={(e) => { 312 + const value = e.currentTarget.value; 313 + if (!value || isRecordKey(value)) setRkeyError(""); 314 + else setRkeyError("Invalid record key: 1-512 chars, use a-z A-Z 0-9 . _ ~ : -"); 315 + }} 316 + /> 317 + </div> 318 + <Show when={collectionError() || rkeyError()}> 319 + <div class="text-xs text-red-500 dark:text-red-400"> 320 + <div>{collectionError()}</div> 321 + <div>{rkeyError()}</div> 322 + </div> 323 + </Show> 324 + </Show> 325 + <div class="min-h-0 flex-1"> 326 + <Suspense 327 + fallback={ 328 + <div class="flex h-full items-center justify-center"> 329 + <span class="iconify lucide--loader-circle animate-spin text-xl"></span> 330 + </div> 331 + } 332 + > 333 + <Editor 334 + content={JSON.stringify( 335 + !props.create ? props.record 336 + : params.rkey ? placeholder() 337 + : defaultPlaceholder(), 338 + null, 339 + 2, 340 + )} 341 + /> 342 + </Suspense> 343 + </div> 344 + <div class="flex flex-col gap-2"> 345 + <Show when={notice()}> 346 + <div class="text-sm text-red-500 dark:text-red-400">{notice()}</div> 347 + </Show> 348 + <div class="flex justify-between gap-2"> 349 + <div class="relative" ref={insertMenuRef}> 350 + <Button onClick={() => setOpenInsertMenu(!openInsertMenu())}> 351 + <span class="iconify lucide--plus"></span> 352 + <span>Add</span> 353 + </Button> 354 + <Show when={openInsertMenu()}> 355 + <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute bottom-full left-0 z-10 mb-1 flex w-40 flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1.5 shadow-md dark:border-neutral-700"> 356 + <MenuItem 357 + icon="lucide--id-card" 358 + label="Insert DID" 359 + onClick={insertDidFromHandle} 360 + /> 361 + <MenuItem 362 + icon="lucide--clock" 363 + label="Insert timestamp" 364 + onClick={insertTimestamp} 365 + /> 366 + <button 367 + type="button" 368 + class={ 369 + hasUserScope("blob") ? 370 + "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" 371 + : "flex items-center gap-2 rounded-md p-2 text-left text-xs opacity-40" 372 + } 373 + onClick={() => { 374 + if (hasUserScope("blob")) { 375 + setOpenInsertMenu(false); 376 + blobInput.click(); 377 + } 378 + }} 379 + > 380 + <span class="iconify lucide--upload shrink-0"></span> 381 + <span>Upload blob{hasUserScope("blob") ? "" : " (permission needed)"}</span> 382 + </button> 383 + </div> 384 + </Show> 385 + <input 386 + type="file" 387 + id="blob" 388 + class="sr-only" 389 + ref={blobInput} 390 + onChange={(e) => { 391 + if (e.target.files !== null) setOpenUpload(true); 392 + }} 393 + /> 394 + </div> 395 + <Modal 396 + open={openUpload()} 397 + onClose={() => setOpenUpload(false)} 398 + closeOnClick={false} 399 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[20rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 400 + > 401 + <FileUpload 402 + file={blobInput.files![0]} 403 + blobInput={blobInput} 404 + onClose={() => setOpenUpload(false)} 405 + /> 406 + </Modal> 407 + <Modal 408 + open={openHandleDialog()} 409 + onClose={() => setOpenHandleDialog(false)} 410 + closeOnClick={false} 411 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[20rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 412 + > 413 + <HandleInput onClose={() => setOpenHandleDialog(false)} /> 414 + </Modal> 415 + <Modal 416 + open={openConfirmDialog()} 417 + onClose={() => setOpenConfirmDialog(false)} 418 + closeOnClick={false} 419 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[24rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 420 + > 421 + <ConfirmSubmit 422 + isCreate={props.create} 423 + onConfirm={(validate, recreate) => { 424 + if (props.create) { 425 + createRecord(validate); 426 + } else { 427 + editRecord(validate, recreate); 428 + } 429 + }} 430 + onClose={() => setOpenConfirmDialog(false)} 431 + /> 432 + </Modal> 433 + <div class="flex items-center justify-end gap-2"> 434 + <Button onClick={() => setOpenConfirmDialog(true)}> 435 + {props.create ? "Create..." : "Edit..."} 436 + </Button> 437 + </div> 438 + </div> 439 + </div> 440 + </form> 441 + </Modal> 442 + <Show when={isMinimized() && openDialog()}> 443 + <button 444 445 446 ··· 448 449 450 451 + 452 + 453 + 454 + 455 + 456 + 457 + 458 + 459 + 460 + <button 461 + class={ 462 + hasPermission() ? 463 + `flex items-center p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-md" : "rounded-sm"}` 464 + : `flex items-center p-1.5 opacity-40 ${props.create ? "rounded-md" : "rounded-sm"}` 465 + } 466 + onclick={() => { 467 + if (hasPermission()) {
+46 -1
src/components/modal.tsx
··· 5 onClose?: () => void; 6 closeOnClick?: boolean; 7 nonBlocking?: boolean; 8 } 9 10 export const Modal = (props: ModalProps) => { ··· 12 <Show when={props.open}> 13 <div 14 data-modal 15 - class="fixed inset-0 z-50 h-full max-h-none w-full max-w-none bg-transparent text-neutral-900 dark:text-neutral-200" 16 classList={{ 17 "pointer-events-none": props.nonBlocking, 18 }} 19 ref={(node) => { 20 const handleEscape = (e: KeyboardEvent) => {
··· 5 onClose?: () => void; 6 closeOnClick?: boolean; 7 nonBlocking?: boolean; 8 + alignTop?: boolean; 9 + contentClass?: string; 10 } 11 12 export const Modal = (props: ModalProps) => { ··· 14 <Show when={props.open}> 15 <div 16 data-modal 17 + class="fixed inset-0 z-50 flex h-full max-h-none w-full max-w-none justify-center bg-transparent text-neutral-900 dark:text-neutral-200" 18 classList={{ 19 "pointer-events-none": props.nonBlocking, 20 + "items-start pt-18": props.alignTop, 21 + "items-start pt-[20vh]": !props.alignTop, 22 }} 23 ref={(node) => { 24 const handleEscape = (e: KeyboardEvent) => { 25 + 26 + 27 + 28 + 29 + 30 + 31 + 32 + 33 + 34 + 35 + 36 + 37 + 38 + 39 + 40 + 41 + 42 + 43 + 44 + 45 + 46 + 47 + 48 + 49 + 50 + 51 + 52 + 53 + 54 + 55 + } 56 + }} 57 + > 58 + <div 59 + class={`transition-all starting:scale-95 starting:opacity-0 ${props.contentClass ?? ""}`} 60 + > 61 + {props.children} 62 + </div> 63 + </div> 64 + </Show> 65 + );
+20 -6
src/views/record.tsx
··· 415 416 417 418 - 419 <span class="iconify lucide--trash-2"></span> 420 </PermissionButton> 421 - <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 422 - <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"> 423 - <h2 class="mb-2 font-semibold">Delete this record?</h2> 424 - <div class="flex justify-end gap-2"> 425 - <Button onClick={() => setOpenDelete(false)}>Cancel</Button>
··· 415 416 417 418 + > 419 <span class="iconify lucide--trash-2"></span> 420 </PermissionButton> 421 + <Modal 422 + open={openDelete()} 423 + onClose={() => setOpenDelete(false)} 424 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 425 + > 426 + <h2 class="mb-2 font-semibold">Delete this record?</h2> 427 + <div class="flex justify-end gap-2"> 428 + <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 429 + <Button 430 + onClick={deleteRecord} 431 + classList={{ 432 + "bg-red-500! border-none! text-white! hover:bg-red-400! active:bg-red-400!": true, 433 + }} 434 + > 435 + Delete 436 + </Button> 437 + </div> 438 + </Modal> 439 + </Show>
+363 -315
src/views/stream/index.tsx
··· 3 import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 import { Button } from "../../components/button"; 6 import { JSONValue } from "../../components/json"; 7 - import { StickyOverlay } from "../../components/sticky"; 8 import { TextInput } from "../../components/text-input"; 9 import { StreamStats, StreamStatsPanel } from "./stats"; 10 11 const LIMIT = 20; 12 - type Parameter = { name: string; param: string | string[] | undefined }; 13 14 - const StreamView = () => { 15 const [searchParams, setSearchParams] = useSearchParams(); 16 - const [parameters, setParameters] = createSignal<Parameter[]>([]); 17 - const streamType = useLocation().pathname === "/firehose" ? "firehose" : "jetstream"; 18 - const [records, setRecords] = createSignal<Array<any>>([]); 19 const [connected, setConnected] = createSignal(false); 20 const [paused, setPaused] = createSignal(false); 21 const [notice, setNotice] = createSignal(""); 22 23 24 25 26 27 28 29 30 ··· 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 ··· 67 68 69 70 71 72 73 74 75 ··· 77 78 79 80 81 82 83 - 84 - 85 - 86 - 87 - 88 - 89 - 90 - 91 - 92 - 93 - 94 - 95 - 96 - 97 - 98 - 99 - 100 - 101 - 102 - 103 - 104 - 105 - 106 - 107 - 108 - 109 - 110 - 111 - 112 - 113 - 114 - 115 - 116 - 117 - 118 - 119 - 120 - 121 - 122 - 123 - 124 - 125 - 126 - 127 - 128 - 129 - 130 - 131 - 132 - 133 - 134 - 135 - 136 - 137 - 138 - 139 - 140 - 141 - 142 - 143 - 144 - 145 - 146 - 147 - 148 - 149 - 150 - 151 - 152 - 153 - 154 - 155 - 156 - 157 - 158 - 159 - 160 - 161 - 162 - 163 - 164 - 165 - 166 - 167 - 168 - 169 - 170 - 171 - 172 - 173 - 174 - 175 - 176 - 177 - 178 - 179 - 180 - 181 - 182 - 183 - 184 - 185 - 186 - 187 - 188 - 189 - 190 - 191 - 192 - 193 - 194 - 195 - 196 - 197 - 198 - 199 - 200 - 201 - 202 - 203 - 204 - 205 - 206 - 207 - 208 - 209 - 210 - 211 - 212 - 213 - 214 - 215 - 216 - 217 - 218 - 219 - 220 - 221 - 222 - 223 - 224 - 225 - 226 - 227 - 228 - 229 - 230 - 231 - 232 - 233 - 234 - 235 - 236 - 237 - 238 - 239 - 240 - 241 - 242 - 243 - 244 - 245 - 246 - 247 - 248 - 249 - 250 - 251 - 252 - 253 - 254 - 255 - 256 - 257 - 258 - 259 - 260 - 261 - 262 - 263 - 264 - 265 266 return ( 267 <> 268 - <Title>{streamType === "firehose" ? "Firehose" : "Jetstream"} - PDSls</Title> 269 - <div class="flex w-full flex-col items-center"> 270 <div class="flex gap-4 font-medium"> 271 - <A 272 - class="flex items-center gap-1 border-b-2" 273 - 274 - 275 - 276 - 277 - 278 - 279 - 280 - 281 - 282 - 283 284 - </A> 285 </div> 286 <Show when={!connected()}> 287 - <form ref={formRef} class="mt-4 mb-4 flex w-full flex-col gap-1.5 px-2 text-sm"> 288 <label class="flex items-center justify-end gap-x-1"> 289 - <span class="min-w-20">Instance</span> 290 <TextInput 291 292 - 293 - 294 - 295 - 296 - 297 - 298 - 299 - 300 - 301 - 302 - 303 - 304 - 305 - 306 - 307 - 308 - 309 - 310 - 311 - 312 - 313 - 314 - 315 - 316 - 317 - 318 - 319 - 320 - 321 - 322 - 323 - 324 - 325 - 326 - 327 - 328 - 329 - 330 - 331 - 332 - 333 - 334 - 335 - 336 - 337 - 338 - 339 - 340 - 341 - 342 - 343 - 344 - 345 - 346 - 347 - 348 - 349 - 350 </form> 351 </Show> 352 <Show when={connected()}> 353 - <StickyOverlay> 354 - <div class="flex w-full flex-col gap-2 p-1"> 355 - <div class="flex flex-col gap-1 text-sm wrap-anywhere"> 356 - <div class="font-semibold">Parameters</div> 357 - <For each={parameters()}> 358 - {(param) => ( 359 - <Show when={param.param}> 360 - <div class="text-sm"> 361 - <div class="text-xs text-neutral-500 dark:text-neutral-400"> 362 - {param.name} 363 - </div> 364 - <div class="text-neutral-700 dark:text-neutral-300">{param.param}</div> 365 - </div> 366 - </Show> 367 - )} 368 - </For> 369 - </div> 370 - <StreamStatsPanel stats={stats()} currentTime={currentTime()} /> 371 - <div class="flex justify-end gap-2"> 372 - <button 373 - type="button" 374 - ontouchstart={(e) => { 375 - e.preventDefault(); 376 - requestAnimationFrame(() => togglePause()); 377 - }} 378 - onclick={togglePause} 379 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-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" 380 - > 381 - {paused() ? "Resume" : "Pause"} 382 - </button> 383 - <button 384 - type="button" 385 - ontouchstart={(e) => { 386 - e.preventDefault(); 387 - requestAnimationFrame(() => disconnect()); 388 - }} 389 - onclick={disconnect} 390 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-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" 391 - > 392 - Disconnect 393 - </button> 394 - </div> 395 </div> 396 - </StickyOverlay> 397 </Show> 398 <Show when={notice().length}> 399 <div class="text-red-500 dark:text-red-400">{notice()}</div> 400 </Show> 401 - <div class="flex w-full flex-col gap-2 divide-y-[0.5px] divide-neutral-500 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:text-sm md:w-3xl"> 402 - <For each={records().toReversed()}> 403 - {(rec) => ( 404 - <div class="pb-2"> 405 - <JSONValue data={rec} repo={rec.did ?? rec.repo} hideBlobs /> 406 - </div> 407 - )} 408 - </For> 409 - </div> 410 </div> 411 </> 412 );
··· 3 import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 import { Button } from "../../components/button"; 6 + import DidHoverCard from "../../components/hover-card/did"; 7 import { JSONValue } from "../../components/json"; 8 import { TextInput } from "../../components/text-input"; 9 + import { addToClipboard } from "../../utils/copy"; 10 + import { getStreamType, STREAM_CONFIGS, STREAM_TYPES, StreamType } from "./config"; 11 import { StreamStats, StreamStatsPanel } from "./stats"; 12 13 const LIMIT = 20; 14 15 + const TYPE_COLORS: Record<string, string> = { 16 + create: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300", 17 + update: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300", 18 + delete: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300", 19 + identity: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300", 20 + account: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300", 21 + sync: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300", 22 + }; 23 + 24 + const StreamRecordItem = (props: { record: any; streamType: StreamType }) => { 25 + const [expanded, setExpanded] = createSignal(false); 26 + const config = () => STREAM_CONFIGS[props.streamType]; 27 + const info = () => config().parseRecord(props.record); 28 + 29 + const displayType = () => { 30 + const i = info(); 31 + return i.type === "commit" || i.type === "link" ? i.action : i.type; 32 + }; 33 + 34 + const copyRecord = (e: MouseEvent) => { 35 + e.stopPropagation(); 36 + addToClipboard(JSON.stringify(props.record, null, 2)); 37 + }; 38 + 39 + return ( 40 + <div class="flex flex-col gap-2"> 41 + <div class="flex items-start gap-1"> 42 + <button 43 + type="button" 44 + onclick={() => setExpanded(!expanded())} 45 + class="dark:hover:bg-dark-200 flex min-w-0 flex-1 items-start gap-2 rounded p-1 text-left hover:bg-neutral-200/70" 46 + > 47 + <span class="mt-0.5 shrink-0 text-neutral-400 dark:text-neutral-500"> 48 + {expanded() ? 49 + <span class="iconify lucide--chevron-down"></span> 50 + : <span class="iconify lucide--chevron-right"></span>} 51 + </span> 52 + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> 53 + <div class="flex items-center gap-x-1.5 sm:gap-x-2"> 54 + <span 55 + class={`shrink-0 rounded px-1.5 py-0.5 text-xs font-medium ${TYPE_COLORS[displayType()!] || "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300"}`} 56 + > 57 + {displayType()} 58 + </span> 59 + <Show when={info().collection && info().collection !== info().type}> 60 + <span class="min-w-0 truncate text-neutral-600 dark:text-neutral-300"> 61 + {info().collection} 62 + </span> 63 + </Show> 64 + <Show when={info().rkey}> 65 + <span class="truncate text-neutral-400 dark:text-neutral-500">{info().rkey}</span> 66 + </Show> 67 + </div> 68 + <div class="flex flex-col gap-x-2 gap-y-0.5 text-xs text-neutral-500 sm:flex-row sm:items-center dark:text-neutral-400"> 69 + <Show when={info().did}> 70 + <span class="w-fit" onclick={(e) => e.stopPropagation()}> 71 + <DidHoverCard newTab did={info().did!} /> 72 + </span> 73 + </Show> 74 + <Show when={info().time}> 75 + <span>{info().time}</span> 76 + </Show> 77 + </div> 78 + </div> 79 + </button> 80 + <Show when={expanded()}> 81 + <button 82 + type="button" 83 + onclick={copyRecord} 84 + class="flex size-6 shrink-0 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-200 hover:text-neutral-600 active:bg-neutral-300 sm:size-7 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:active:bg-neutral-600" 85 + > 86 + <span class="iconify lucide--copy"></span> 87 + </button> 88 + </Show> 89 + </div> 90 + <Show when={expanded()}> 91 + <div class="ml-6.5"> 92 + <div class="w-full text-xs wrap-anywhere whitespace-pre-wrap md:w-2xl"> 93 + <JSONValue newTab data={props.record} repo={info().did ?? ""} hideBlobs /> 94 + </div> 95 + </div> 96 + </Show> 97 + </div> 98 + ); 99 + }; 100 + 101 + export const StreamView = () => { 102 const [searchParams, setSearchParams] = useSearchParams(); 103 + const streamType = getStreamType(useLocation().pathname); 104 + const config = () => STREAM_CONFIGS[streamType]; 105 + 106 + const [records, setRecords] = createSignal<any[]>([]); 107 const [connected, setConnected] = createSignal(false); 108 const [paused, setPaused] = createSignal(false); 109 const [notice, setNotice] = createSignal(""); 110 + const [parameters, setParameters] = createSignal<{ name: string; value?: string }[]>([]); 111 + const [stats, setStats] = createSignal<StreamStats>({ 112 + totalEvents: 0, 113 + eventsPerSecond: 0, 114 115 + collections: {}, 116 + }); 117 + const [currentTime, setCurrentTime] = createSignal(Date.now()); 118 119 + let socket: WebSocket; 120 + let firehose: Firehose; 121 + let formRef!: HTMLFormElement; 122 123 + let rafId: number | null = null; 124 + let statsIntervalId: number | null = null; 125 + let statsUpdateIntervalId: number | null = null; 126 + let currentSecondEventCount = 0; 127 + let totalEventsCount = 0; 128 + let eventTypesMap: Record<string, number> = {}; 129 + let collectionsMap: Record<string, number> = {}; 130 131 + const addRecord = (record: any) => { 132 + currentSecondEventCount++; 133 + totalEventsCount++; 134 135 + const rawEventType = record.kind || record.$type || "unknown"; 136 + const eventType = rawEventType.includes("#") ? rawEventType.split("#").pop() : rawEventType; 137 + eventTypesMap[eventType] = (eventTypesMap[eventType] || 0) + 1; 138 139 + if (eventType !== "account" && eventType !== "identity") { 140 + const collection = 141 + record.commit?.collection || 142 + record.op?.path?.split("/")[0] || 143 + record.link?.source || 144 + "unknown"; 145 + collectionsMap[collection] = (collectionsMap[collection] || 0) + 1; 146 + } 147 148 149 ··· 155 156 157 158 + }; 159 160 + const disconnect = () => { 161 + if (!config().useFirehoseLib) socket?.close(); 162 + else firehose?.close(); 163 164 + if (rafId !== null) { 165 + cancelAnimationFrame(rafId); 166 + rafId = null; 167 168 169 170 171 172 173 + clearInterval(statsUpdateIntervalId); 174 + statsUpdateIntervalId = null; 175 + } 176 177 + pendingRecords = []; 178 + totalEventsCount = 0; 179 + eventTypesMap = {}; 180 + collectionsMap = {}; 181 + setConnected(false); 182 + setPaused(false); 183 + setStats((prev) => ({ ...prev, eventsPerSecond: 0 })); 184 + }; 185 186 + const connectStream = async (formData: FormData) => { 187 + setNotice(""); 188 + if (connected()) { 189 + disconnect(); 190 191 + } 192 + setRecords([]); 193 194 + const instance = formData.get("instance")?.toString() ?? config().defaultInstance; 195 + const url = config().buildUrl(instance, formData); 196 197 + // Save all form fields to URL params 198 + const params: Record<string, string | undefined> = { instance }; 199 + config().fields.forEach((field) => { 200 + params[field.searchParam] = formData.get(field.name)?.toString(); 201 + }); 202 + setSearchParams(params); 203 204 + // Build parameters display 205 + setParameters([ 206 + { name: "Instance", value: instance }, 207 + ...config() 208 + .fields.filter((f) => f.type !== "checkbox") 209 + .map((f) => ({ name: f.label, value: formData.get(f.name)?.toString() })), 210 + ...config() 211 + .fields.filter((f) => f.type === "checkbox" && formData.get(f.name) === "on") 212 + .map((f) => ({ name: f.label, value: "on" })), 213 + ]); 214 215 + setConnected(true); 216 + const now = Date.now(); 217 + setCurrentTime(now); 218 219 + totalEventsCount = 0; 220 + eventTypesMap = {}; 221 + collectionsMap = {}; 222 223 224 ··· 234 235 236 237 + })); 238 + }, 50); 239 240 + statsIntervalId = window.setInterval(() => { 241 + setStats((prev) => ({ ...prev, eventsPerSecond: currentSecondEventCount })); 242 + currentSecondEventCount = 0; 243 + setCurrentTime(Date.now()); 244 + }, 1000); 245 246 + if (!config().useFirehoseLib) { 247 + socket = new WebSocket(url); 248 + socket.addEventListener("message", (event) => { 249 + const rec = JSON.parse(event.data); 250 + const isFilteredEvent = rec.kind === "account" || rec.kind === "identity"; 251 + if (!isFilteredEvent || streamType !== "jetstream" || searchParams.allEvents === "on") 252 + addRecord(rec); 253 + }); 254 + socket.addEventListener("error", () => { 255 256 + disconnect(); 257 + }); 258 + } else { 259 + const cursor = formData.get("cursor")?.toString(); 260 + firehose = new Firehose({ 261 + relay: url, 262 + cursor: cursor, 263 264 265 ··· 267 268 269 270 + }); 271 + firehose.on("commit", (commit) => { 272 + for (const op of commit.ops) { 273 + addRecord({ 274 + $type: commit.$type, 275 + repo: commit.repo, 276 + seq: commit.seq, 277 278 + rev: commit.rev, 279 + since: commit.since, 280 + op: op, 281 + }); 282 + } 283 + }); 284 + firehose.on("identity", (identity) => addRecord(identity)); 285 + firehose.on("account", (account) => addRecord(account)); 286 + firehose.on("sync", (sync) => { 287 + addRecord({ 288 + $type: sync.$type, 289 + did: sync.did, 290 + rev: sync.rev, 291 + seq: sync.seq, 292 + time: sync.time, 293 + }); 294 + }); 295 + firehose.start(); 296 + } 297 + }; 298 299 + onMount(() => { 300 + if (searchParams.instance) { 301 + const formData = new FormData(); 302 + formData.append("instance", searchParams.instance.toString()); 303 + config().fields.forEach((field) => { 304 + const value = searchParams[field.searchParam]; 305 + if (value) formData.append(field.name, value.toString()); 306 + }); 307 + connectStream(formData); 308 + } 309 + }); 310 311 + onCleanup(() => { 312 + socket?.close(); 313 + firehose?.close(); 314 + if (rafId !== null) cancelAnimationFrame(rafId); 315 + if (statsIntervalId !== null) clearInterval(statsIntervalId); 316 + if (statsUpdateIntervalId !== null) clearInterval(statsUpdateIntervalId); 317 + }); 318 319 return ( 320 <> 321 + <Title>{config().label} - PDSls</Title> 322 + <div class="flex w-full flex-col items-center gap-2"> 323 + {/* Tab Navigation */} 324 <div class="flex gap-4 font-medium"> 325 + <For each={STREAM_TYPES}> 326 + {(type) => ( 327 + <A 328 + class="flex items-center gap-1 border-b-2" 329 + inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 330 + href={`/${type}`} 331 + > 332 + {STREAM_CONFIGS[type].label} 333 + </A> 334 + )} 335 + </For> 336 + </div> 337 338 + {/* Stream Description */} 339 + <div class="w-full px-2 text-center"> 340 + <p class="text-sm text-neutral-600 dark:text-neutral-400">{config().description}</p> 341 </div> 342 + 343 + {/* Connection Form */} 344 <Show when={!connected()}> 345 + <form ref={formRef} class="flex w-full flex-col gap-2 p-2 text-sm"> 346 <label class="flex items-center justify-end gap-x-1"> 347 + <span class="min-w-21 select-none">Instance</span> 348 <TextInput 349 + name="instance" 350 + value={searchParams.instance ?? config().defaultInstance} 351 + class="grow" 352 + /> 353 + </label> 354 + 355 + <For each={config().fields}> 356 + {(field) => ( 357 + <label class="flex items-center justify-end gap-x-1"> 358 + <Show when={field.type === "checkbox"}> 359 + <input 360 + type="checkbox" 361 + name={field.name} 362 + id={field.name} 363 + checked={searchParams[field.searchParam] === "on"} 364 + /> 365 + </Show> 366 + <span class="min-w-21 select-none">{field.label}</span> 367 + <Show when={field.type === "textarea"}> 368 + <textarea 369 + name={field.name} 370 + spellcheck={false} 371 + placeholder={field.placeholder} 372 + value={(searchParams[field.searchParam] as string) ?? ""} 373 + class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 374 + /> 375 + </Show> 376 + <Show when={field.type === "text"}> 377 + <TextInput 378 + name={field.name} 379 + placeholder={field.placeholder} 380 + value={(searchParams[field.searchParam] as string) ?? ""} 381 + class="grow" 382 + /> 383 + </Show> 384 + </label> 385 + )} 386 + </For> 387 388 + <div class="flex justify-end gap-2"> 389 + <Button onClick={() => connectStream(new FormData(formRef))}>Connect</Button> 390 + </div> 391 </form> 392 </Show> 393 + 394 + {/* Connected State */} 395 <Show when={connected()}> 396 + <div class="flex w-full flex-col gap-2 p-2"> 397 + <div class="flex flex-col gap-1 text-sm wrap-anywhere"> 398 + <div class="font-semibold">Parameters</div> 399 + <For each={parameters()}> 400 + {(param) => ( 401 + <Show when={param.value}> 402 + <div class="text-sm"> 403 + <div class="text-xs text-neutral-500 dark:text-neutral-400">{param.name}</div> 404 + <div class="text-neutral-700 dark:text-neutral-300">{param.value}</div> 405 + </div> 406 + </Show> 407 + )} 408 + </For> 409 + </div> 410 + <StreamStatsPanel 411 + stats={stats()} 412 + currentTime={currentTime()} 413 + streamType={streamType} 414 + showAllEvents={searchParams.allEvents === "on"} 415 + /> 416 + <div class="flex justify-end gap-2"> 417 + <Button 418 + ontouchstart={(e) => { 419 + e.preventDefault(); 420 + requestAnimationFrame(() => setPaused(!paused())); 421 + }} 422 + onClick={() => setPaused(!paused())} 423 + > 424 + {paused() ? "Resume" : "Pause"} 425 + </Button> 426 + <Button 427 + ontouchstart={(e) => { 428 + e.preventDefault(); 429 + requestAnimationFrame(() => disconnect()); 430 + }} 431 + onClick={disconnect} 432 + > 433 + Disconnect 434 + </Button> 435 </div> 436 + </div> 437 </Show> 438 + 439 + {/* Error Notice */} 440 <Show when={notice().length}> 441 <div class="text-red-500 dark:text-red-400">{notice()}</div> 442 </Show> 443 + 444 + {/* Records List */} 445 + <Show when={connected() || records().length > 0}> 446 + <div class="flex min-h-280 w-full flex-col gap-2 font-mono text-xs [overflow-anchor:auto] sm:text-sm"> 447 + <For each={records().toReversed()}> 448 + {(rec) => ( 449 + <div class="[overflow-anchor:none]"> 450 + <StreamRecordItem record={rec} streamType={streamType} /> 451 + </div> 452 + )} 453 + </For> 454 + <div class="h-px [overflow-anchor:auto]" /> 455 + </div> 456 + </Show> 457 </div> 458 </> 459 ); 460 + };
+221
src/views/stream/config.ts
···
··· 1 + import { localDateFromTimestamp } from "../../utils/date"; 2 + 3 + export type StreamType = "jetstream" | "firehose" | "spacedust"; 4 + 5 + export type FormField = { 6 + name: string; 7 + label: string; 8 + type: "text" | "textarea" | "checkbox"; 9 + placeholder?: string; 10 + searchParam: string; 11 + }; 12 + 13 + export type RecordInfo = { 14 + type: string; 15 + did?: string; 16 + collection?: string; 17 + rkey?: string; 18 + action?: string; 19 + time?: string; 20 + }; 21 + 22 + export type StreamConfig = { 23 + label: string; 24 + description: string; 25 + icon: string; 26 + defaultInstance: string; 27 + fields: FormField[]; 28 + useFirehoseLib: boolean; 29 + buildUrl: (instance: string, formData: FormData) => string; 30 + parseRecord: (record: any) => RecordInfo; 31 + showEventTypes: boolean; 32 + collectionsLabel: string; 33 + }; 34 + 35 + export const STREAM_CONFIGS: Record<StreamType, StreamConfig> = { 36 + jetstream: { 37 + label: "Jetstream", 38 + description: "A simplified event stream with support for collection and DID filtering.", 39 + icon: "lucide--radio-tower", 40 + defaultInstance: "wss://jetstream1.us-east.bsky.network/subscribe", 41 + useFirehoseLib: false, 42 + showEventTypes: true, 43 + collectionsLabel: "Top Collections", 44 + fields: [ 45 + { 46 + name: "collections", 47 + label: "Collections", 48 + type: "textarea", 49 + placeholder: "Comma-separated list of collections", 50 + searchParam: "collections", 51 + }, 52 + { 53 + name: "dids", 54 + label: "DIDs", 55 + type: "textarea", 56 + placeholder: "Comma-separated list of DIDs", 57 + searchParam: "dids", 58 + }, 59 + { 60 + name: "cursor", 61 + label: "Cursor", 62 + type: "text", 63 + placeholder: "Leave empty for live-tail", 64 + searchParam: "cursor", 65 + }, 66 + { 67 + name: "allEvents", 68 + label: "Show account and identity events", 69 + type: "checkbox", 70 + searchParam: "allEvents", 71 + }, 72 + ], 73 + buildUrl: (instance, formData) => { 74 + let url = instance + "?"; 75 + 76 + const collections = formData.get("collections")?.toString().split(","); 77 + collections?.forEach((c) => { 78 + if (c.trim().length) url += `wantedCollections=${c.trim()}&`; 79 + }); 80 + 81 + const dids = formData.get("dids")?.toString().split(","); 82 + dids?.forEach((d) => { 83 + if (d.trim().length) url += `wantedDids=${d.trim()}&`; 84 + }); 85 + 86 + const cursor = formData.get("cursor")?.toString(); 87 + if (cursor?.length) url += `cursor=${cursor}&`; 88 + 89 + return url.replace(/[&?]$/, ""); 90 + }, 91 + parseRecord: (rec) => { 92 + const collection = rec.commit?.collection || rec.kind; 93 + const rkey = rec.commit?.rkey; 94 + const action = rec.commit?.operation; 95 + const time = rec.time_us ? localDateFromTimestamp(rec.time_us / 1000) : undefined; 96 + return { type: rec.kind, did: rec.did, collection, rkey, action, time }; 97 + }, 98 + }, 99 + 100 + firehose: { 101 + label: "Firehose", 102 + description: "The raw event stream from a relay or PDS.", 103 + icon: "lucide--rss", 104 + defaultInstance: "wss://bsky.network", 105 + useFirehoseLib: true, 106 + showEventTypes: true, 107 + collectionsLabel: "Top Collections", 108 + fields: [ 109 + { 110 + name: "cursor", 111 + label: "Cursor", 112 + type: "text", 113 + placeholder: "Leave empty for live-tail", 114 + searchParam: "cursor", 115 + }, 116 + ], 117 + buildUrl: (instance, _formData) => { 118 + let url = instance; 119 + url = url.replace("/xrpc/com.atproto.sync.subscribeRepos", ""); 120 + if (!(url.startsWith("wss://") || url.startsWith("ws://"))) { 121 + url = "wss://" + url; 122 + } 123 + return url; 124 + }, 125 + parseRecord: (rec) => { 126 + const type = rec.$type?.split("#").pop() || rec.$type; 127 + const did = rec.repo ?? rec.did; 128 + const pathParts = rec.op?.path?.split("/") || []; 129 + const collection = pathParts[0]; 130 + const rkey = pathParts[1]; 131 + const time = rec.time ? localDateFromTimestamp(Date.parse(rec.time)) : undefined; 132 + return { type, did, collection, rkey, action: rec.op?.action, time }; 133 + }, 134 + }, 135 + 136 + spacedust: { 137 + label: "Spacedust", 138 + description: "A stream of links showing interactions across the network.", 139 + icon: "lucide--link", 140 + defaultInstance: "wss://spacedust.microcosm.blue/subscribe", 141 + useFirehoseLib: false, 142 + showEventTypes: false, 143 + collectionsLabel: "Top Sources", 144 + fields: [ 145 + { 146 + name: "sources", 147 + label: "Sources", 148 + type: "textarea", 149 + placeholder: "e.g. app.bsky.graph.follow:subject", 150 + searchParam: "sources", 151 + }, 152 + { 153 + name: "subjectDids", 154 + label: "Subject DIDs", 155 + type: "textarea", 156 + placeholder: "Comma-separated list of DIDs", 157 + searchParam: "subjectDids", 158 + }, 159 + { 160 + name: "subjects", 161 + label: "Subjects", 162 + type: "textarea", 163 + placeholder: "Comma-separated list of AT URIs", 164 + searchParam: "subjects", 165 + }, 166 + { 167 + name: "instant", 168 + label: "Instant mode (bypass 21s delay buffer)", 169 + type: "checkbox", 170 + searchParam: "instant", 171 + }, 172 + ], 173 + buildUrl: (instance, formData) => { 174 + let url = instance + "?"; 175 + 176 + const sources = formData.get("sources")?.toString().split(","); 177 + sources?.forEach((s) => { 178 + if (s.trim().length) url += `wantedSources=${s.trim()}&`; 179 + }); 180 + 181 + const subjectDids = formData.get("subjectDids")?.toString().split(","); 182 + subjectDids?.forEach((d) => { 183 + if (d.trim().length) url += `wantedSubjectDids=${d.trim()}&`; 184 + }); 185 + 186 + const subjects = formData.get("subjects")?.toString().split(","); 187 + subjects?.forEach((s) => { 188 + if (s.trim().length) url += `wantedSubjects=${encodeURIComponent(s.trim())}&`; 189 + }); 190 + 191 + const instant = formData.get("instant")?.toString(); 192 + if (instant === "on") url += `instant=true&`; 193 + 194 + return url.replace(/[&?]$/, ""); 195 + }, 196 + parseRecord: (rec) => { 197 + const source = rec.link?.source; 198 + const sourceRecord = rec.link?.source_record; 199 + const uriParts = sourceRecord?.replace("at://", "").split("/") || []; 200 + const did = uriParts[0]; 201 + const collection = uriParts[1]; 202 + const rkey = uriParts[2]; 203 + return { 204 + type: rec.kind, 205 + did, 206 + collection: source || collection, 207 + rkey, 208 + action: rec.link?.operation, 209 + time: undefined, 210 + }; 211 + }, 212 + }, 213 + }; 214 + 215 + export const STREAM_TYPES = Object.keys(STREAM_CONFIGS) as StreamType[]; 216 + 217 + export const getStreamType = (pathname: string): StreamType => { 218 + if (pathname === "/firehose") return "firehose"; 219 + if (pathname === "/spacedust") return "spacedust"; 220 + return "jetstream"; 221 + };
+14 -12
src/layout.tsx
··· 11 12 13 14 15 16 17 18 ··· 142 143 144 145 - 146 - 147 - 148 - 149 - 150 - 151 - 152 - 153 - 154 - 155 - 156 </Show> 157 <AccountManager /> 158 <MenuProvider> 159 - <DropdownMenu icon="lucide--menu text-lg" buttonClass="rounded-lg p-1.5"> 160 <NavMenu href="/jetstream" label="Jetstream" icon="lucide--radio-tower" /> 161 <NavMenu href="/firehose" label="Firehose" icon="lucide--rss" /> 162 <NavMenu href="/spacedust" label="Spacedust" icon="lucide--orbit" />
··· 11 12 13 14 + import { resolveHandle } from "./utils/api.js"; 15 + import { plcDirectory } from "./views/settings.jsx"; 16 17 + export const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches; 18 19 + const headers: Record<string, string> = { 20 + "did:plc:ia76kvnndjutgedggx2ibrem": "bunny.jpg", 21 22 23 ··· 147 148 149 150 + </Show> 151 + </A> 152 + <div class="relative flex items-center gap-0.5 rounded-lg bg-neutral-50/60 p-1 dark:bg-neutral-800/60"> 153 + <div class="mr-1"> 154 + <SearchButton /> 155 + </div> 156 + <Show when={agent()}> 157 + <RecordEditor create={true} scope="create" /> 158 </Show> 159 <AccountManager /> 160 <MenuProvider> 161 + <DropdownMenu icon="lucide--menu text-lg" buttonClass="rounded-md p-1.5"> 162 <NavMenu href="/jetstream" label="Jetstream" icon="lucide--radio-tower" /> 163 <NavMenu href="/firehose" label="Firehose" icon="lucide--rss" /> 164 <NavMenu href="/spacedust" label="Spacedust" icon="lucide--orbit" />
+338 -1
src/views/collection.tsx
··· 40 class="flex w-full min-w-0 items-baseline rounded px-1 py-0.5" 41 trigger={ 42 <> 43 - <span class="shrink-0 text-sm text-blue-500 dark:text-blue-400">{props.record.rkey}</span> 44 <span class="ml-1 truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 45 {props.record.cid} 46 </span>
··· 40 class="flex w-full min-w-0 items-baseline rounded px-1 py-0.5" 41 trigger={ 42 <> 43 + <span class="max-w-full shrink-0 truncate text-sm text-blue-500 dark:text-blue-400"> 44 + {props.record.rkey} 45 + </span> 46 <span class="ml-1 truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 47 {props.record.cid} 48 </span> 49 + 50 + 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + 63 + 64 + 65 + 66 + 67 + 68 + 69 + 70 + 71 + 72 + 73 + 74 + 75 + 76 + 77 + 78 + 79 + 80 + 81 + 82 + 83 + 84 + 85 + 86 + 87 + 88 + 89 + 90 + 91 + 92 + 93 + 94 + 95 + 96 + 97 + 98 + 99 + 100 + 101 + 102 + 103 + 104 + 105 + 106 + 107 + 108 + 109 + 110 + 111 + 112 + 113 + 114 + 115 + 116 + 117 + 118 + 119 + 120 + 121 + 122 + 123 + 124 + 125 + 126 + 127 + 128 + 129 + 130 + 131 + 132 + 133 + 134 + 135 + 136 + 137 + 138 + 139 + 140 + 141 + 142 + 143 + 144 + 145 + 146 + 147 + 148 + 149 + 150 + 151 + 152 + 153 + 154 + 155 + 156 + 157 + 158 + 159 + 160 + 161 + 162 + 163 + 164 + 165 + 166 + 167 + 168 + 169 + 170 + 171 + 172 + 173 + 174 + 175 + 176 + 177 + 178 + 179 + 180 + 181 + 182 + 183 + 184 + 185 + 186 + 187 + 188 + 189 + 190 + 191 + 192 + 193 + 194 + 195 + 196 + 197 + 198 + 199 + 200 + 201 + 202 + 203 + 204 + 205 + 206 + 207 + 208 + 209 + 210 + 211 + 212 + 213 + 214 + 215 + 216 + 217 + 218 + 219 + 220 + 221 + 222 + 223 + 224 + 225 + 226 + 227 + 228 + 229 + 230 + 231 + 232 + 233 + 234 + 235 + 236 + 237 + 238 + 239 + 240 + 241 + 242 + 243 + 244 + 245 + 246 + 247 + 248 + 249 + 250 + 251 + 252 + 253 + 254 + 255 + 256 + 257 + 258 + 259 + 260 + 261 + 262 + 263 + 264 + 265 + 266 + 267 + 268 + 269 + 270 + 271 + 272 + 273 + 274 + 275 + 276 + 277 + 278 + 279 + 280 + 281 + 282 + 283 + 284 + 285 + 286 + 287 + 288 + 289 + 290 + 291 + 292 + 293 + 294 + 295 + 296 + 297 + 298 + 299 + 300 + 301 + 302 + 303 + 304 + 305 + 306 + 307 + 308 + 309 + 310 + 311 + 312 + 313 + 314 + 315 + <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 316 + <Button 317 + onClick={deleteRecords} 318 + classList={{ 319 + "bg-blue-500! text-white! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400! border-none!": 320 + recreate(), 321 + "text-white! border-none! bg-red-500! hover:bg-red-600! active:bg-red-700!": 322 + !recreate(), 323 + }} 324 + > 325 + {recreate() ? "Recreate" : "Delete"} 326 + </Button> 327 + 328 + 329 + 330 + 331 + 332 + 333 + 334 + 335 + 336 + 337 + 338 + 339 + 340 + 341 + 342 + 343 + 344 + 345 + 346 + 347 + 348 + 349 + 350 + 351 + 352 + 353 + 354 + 355 + 356 + 357 + 358 + 359 + 360 + 361 + 362 + 363 + 364 + 365 + 366 + 367 + 368 + 369 + 370 + 371 + 372 + 373 + 374 + 375 + 376 + 377 + <Button onClick={() => refetch()}>Load more</Button> 378 + </Show> 379 + <Show when={response.loading}> 380 + <div class="iconify lucide--loader-circle w-20 animate-spin text-lg" /> 381 + </Show> 382 + </Show> 383 + </div>
+36 -26
src/auth/scope-selector.tsx
··· 44 }; 45 46 return ( 47 - <div class="flex flex-col gap-y-2"> 48 - <div class="mb-1 flex items-center gap-2"> 49 <button 50 onclick={props.onCancel} 51 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" 52 53 54 - 55 - 56 - 57 - 58 - 59 - 60 - 61 - 62 - 63 - 64 - 65 - 66 - 67 - 68 - 69 - 70 - 71 - 72 - 73 - 74 - 75 - 76 - 77 </div> 78 <button 79 onclick={handleConfirm} 80 - class="mt-2 grow rounded-lg border-[0.5px] border-neutral-300 bg-neutral-100 px-3 py-2 hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 81 > 82 Continue 83 </button>
··· 44 }; 45 46 return ( 47 + <div class="flex flex-col gap-y-3"> 48 + <div class="flex items-center gap-2"> 49 <button 50 onclick={props.onCancel} 51 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" 52 53 54 + </button> 55 + <div class="font-semibold">Select permissions</div> 56 + </div> 57 + <div class="flex flex-col px-1"> 58 + <For each={GRANULAR_SCOPES}> 59 + {(scope) => { 60 + const isSelected = () => selectedScopes().has(scope.id); 61 + const isDisabled = () => scope.id === "blob" && isBlobDisabled(); 62 + 63 + return ( 64 + <button 65 + onclick={() => !isDisabled() && toggleScope(scope.id)} 66 + disabled={isDisabled()} 67 + class="group flex items-center gap-3 py-2" 68 + classList={{ "opacity-50": isDisabled() }} 69 + > 70 + <div 71 + class="flex size-5 items-center justify-center rounded border-2" 72 + classList={{ 73 + "bg-blue-500 border-transparent group-hover:bg-blue-600 group-active:bg-blue-400": 74 + isSelected() && !isDisabled(), 75 + "border-neutral-400 dark:border-neutral-500 group-hover:border-neutral-500 dark:group-hover:border-neutral-400 group-hover:bg-neutral-100 dark:group-hover:bg-neutral-800": 76 + !isSelected() && !isDisabled(), 77 + "border-neutral-300 dark:border-neutral-600": isDisabled(), 78 + }} 79 + > 80 + {isSelected() && <span class="iconify lucide--check text-sm text-white"></span>} 81 + </div> 82 + <span>{scope.label}</span> 83 + </button> 84 + ); 85 + }} 86 + </For> 87 </div> 88 <button 89 onclick={handleConfirm} 90 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 91 > 92 Continue 93 </button>
-14
src/utils/templates.ts
··· 37 link: `https://pinksea.art/${uri.repo}`, 38 icon: "i-pinksea", 39 }), 40 - "blue.linkat.board": (uri) => ({ 41 - label: "Linkat", 42 - link: `https://linkat.blue/${uri.repo}`, 43 - }), 44 "sh.tangled.actor.profile": (uri) => ({ 45 label: "Tangled", 46 link: `https://tangled.org/${uri.repo}`, ··· 51 link: `https://tangled.org/${uri.repo}/${record.name}`, 52 icon: "i-tangled", 53 }), 54 - "pub.leaflet.document": (uri) => ({ 55 - label: "Leaflet", 56 - link: `https://leaflet.pub/p/${uri.repo}/${uri.rkey}`, 57 - icon: "iconify-color i-leaflet", 58 - }), 59 - "pub.leaflet.publication": (uri) => ({ 60 - label: "Leaflet", 61 - link: `https://leaflet.pub/lish/${uri.repo}/${uri.rkey}`, 62 - icon: "iconify-color i-leaflet", 63 - }), 64 };
··· 37 link: `https://pinksea.art/${uri.repo}`, 38 icon: "i-pinksea", 39 }), 40 "sh.tangled.actor.profile": (uri) => ({ 41 label: "Tangled", 42 link: `https://tangled.org/${uri.repo}`, ··· 47 link: `https://tangled.org/${uri.repo}/${record.name}`, 48 icon: "i-tangled", 49 }), 50 };
-12
src/utils/types/lexicons.ts
··· 17 AppBskyLabelerService, 18 ChatBskyActorDeclaration, 19 } from "@atcute/bluesky"; 20 - import { 21 - PubLeafletComment, 22 - PubLeafletDocument, 23 - PubLeafletGraphSubscription, 24 - PubLeafletPublication, 25 - } from "@atcute/leaflet"; 26 import { 27 ShTangledActorProfile, 28 ShTangledFeedStar, ··· 85 "sh.tangled.repo.pull.status.merged": ShTangledRepoPullStatusMerged.mainSchema, 86 "sh.tangled.repo.pull.status.open": ShTangledRepoPullStatusOpen.mainSchema, 87 "sh.tangled.knot": ShTangledKnot.mainSchema, 88 - 89 - // Leaflet 90 - "pub.leaflet.comment": PubLeafletComment.mainSchema, 91 - "pub.leaflet.document": PubLeafletDocument.mainSchema, 92 - "pub.leaflet.graph.subscription": PubLeafletGraphSubscription.mainSchema, 93 - "pub.leaflet.publication": PubLeafletPublication.mainSchema, 94 };
··· 17 AppBskyLabelerService, 18 ChatBskyActorDeclaration, 19 } from "@atcute/bluesky"; 20 import { 21 ShTangledActorProfile, 22 ShTangledFeedStar, ··· 79 "sh.tangled.repo.pull.status.merged": ShTangledRepoPullStatusMerged.mainSchema, 80 "sh.tangled.repo.pull.status.open": ShTangledRepoPullStatusOpen.mainSchema, 81 "sh.tangled.knot": ShTangledKnot.mainSchema, 82 };
+1
.gitignore
··· 2 dist 3 .env 4 .DS_Store
··· 2 dist 3 .env 4 .DS_Store 5 + public/oauth-client-metadata.json
-13
public/oauth-client-metadata.json
··· 1 - { 2 - "client_id": "https://pdsls.dev/oauth-client-metadata.json", 3 - "client_name": "PDSls", 4 - "client_uri": "https://pdsls.dev", 5 - "logo_uri": "https://pdsls.dev/favicon.ico", 6 - "redirect_uris": ["https://pdsls.dev/"], 7 - "scope": "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 8 - "grant_types": ["authorization_code", "refresh_token"], 9 - "response_types": ["code"], 10 - "token_endpoint_auth_method": "none", 11 - "application_type": "web", 12 - "dpop_bound_access_tokens": true 13 - }
···
+35
scripts/generate-oauth-metadata.js
···
··· 1 + import { mkdirSync, writeFileSync } from "fs"; 2 + import { dirname } from "path"; 3 + import { fileURLToPath } from "url"; 4 + 5 + const __filename = fileURLToPath(import.meta.url); 6 + const __dirname = dirname(__filename); 7 + 8 + const domain = process.env.APP_DOMAIN || "pdsls.dev"; 9 + const protocol = process.env.APP_PROTOCOL || "https"; 10 + const baseUrl = `${protocol}://${domain}`; 11 + 12 + const metadata = { 13 + client_id: `${baseUrl}/oauth-client-metadata.json`, 14 + client_name: "PDSls", 15 + client_uri: baseUrl, 16 + logo_uri: `${baseUrl}/favicon.ico`, 17 + redirect_uris: [`${baseUrl}/`], 18 + scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 19 + grant_types: ["authorization_code", "refresh_token"], 20 + response_types: ["code"], 21 + token_endpoint_auth_method: "none", 22 + application_type: "web", 23 + dpop_bound_access_tokens: true, 24 + }; 25 + 26 + const outputPath = `${__dirname}/../public/oauth-client-metadata.json`; 27 + 28 + try { 29 + mkdirSync(dirname(outputPath), { recursive: true }); 30 + writeFileSync(outputPath, JSON.stringify(metadata, null, 2) + "\n"); 31 + console.log(`Generated OAuth metadata for ${baseUrl}`); 32 + } catch (error) { 33 + console.error("Failed to generate OAuth metadata:", error); 34 + process.exit(1); 35 + }
public/avatar/bad-example.com.jpg

This is a binary file and will not be displayed.

public/avatar/futur.blue.jpg

This is a binary file and will not be displayed.

public/avatar/hailey.at.jpg

This is a binary file and will not be displayed.

public/avatar/jaz.sh.jpg

This is a binary file and will not be displayed.

public/avatar/jcsalterego.bsky.social.jpg

This is a binary file and will not be displayed.

public/avatar/juli.ee.jpg

This is a binary file and will not be displayed.

public/avatar/mary.my.id.jpg

This is a binary file and will not be displayed.

public/avatar/retr0.id.jpg

This is a binary file and will not be displayed.

public/avatar/aylac.top.jpg

This is a binary file and will not be displayed.

public/avatar/computer.fish.jpg

This is a binary file and will not be displayed.

public/avatar/dreary.blacksky.app.jpg

This is a binary file and will not be displayed.

public/avatar/emilia.wtf.jpg

This is a binary file and will not be displayed.

public/avatar/futanari.observer.jpg

This is a binary file and will not be displayed.

public/avatar/mofu.run.jpg

This is a binary file and will not be displayed.

public/avatar/natalie.sh.jpg

This is a binary file and will not be displayed.

public/avatar/nekomimi.pet.jpg

This is a binary file and will not be displayed.

public/avatar/nullekko.moe.jpg

This is a binary file and will not be displayed.

public/avatar/paizuri.moe.jpg

This is a binary file and will not be displayed.

public/avatar/quilling.dev.jpg

This is a binary file and will not be displayed.

public/avatar/rainy.pet.jpg

This is a binary file and will not be displayed.

public/avatar/sapphic.moe.jpg

This is a binary file and will not be displayed.

public/avatar/blooym.dev.jpg

This is a binary file and will not be displayed.

public/avatar/isabelroses.com.jpg

This is a binary file and will not be displayed.

public/avatar/isuggest.selfce.st.jpg

This is a binary file and will not be displayed.

public/avatar/anyaustin.bsky.social.jpg

This is a binary file and will not be displayed.

public/avatar/claire.on-her.computer.jpg

This is a binary file and will not be displayed.

public/avatar/cwonus.org.jpg

This is a binary file and will not be displayed.

public/avatar/number-one-warned.rat.mom.jpg

This is a binary file and will not be displayed.

public/avatar/olaren.dev.jpg

This is a binary file and will not be displayed.

public/avatar/coil-habdle.ebil.club.jpg

This is a binary file and will not be displayed.

+31 -36
package.json
··· 10 "build": "vite build", 11 "serve": "vite preview" 12 }, 13 - "pnpm": { 14 - "overrides": { 15 - "seroval": "^1.4.1" 16 - } 17 - }, 18 "devDependencies": { 19 - "@iconify-json/lucide": "^1.2.86", 20 "@iconify/tailwind4": "^1.2.1", 21 "@tailwindcss/vite": "^4.1.18", 22 - "prettier": "^3.8.0", 23 "prettier-plugin-organize-imports": "^4.3.0", 24 "prettier-plugin-tailwindcss": "^0.7.2", 25 "tailwindcss": "^4.1.18", 26 27 28 29 - 30 - 31 - 32 - 33 - 34 - 35 - 36 - 37 - 38 - 39 - 40 - 41 - 42 - 43 - 44 - 45 - 46 - 47 - 48 - 49 - 50 - 51 - 52 - 53 - 54 - 55 - 56 - 57 58 59 60 "@solidjs/router": "^0.15.4", 61 "codemirror": "^6.0.2", 62 "native-file-system-adapter": "^3.0.1", 63 - "solid-js": "^1.9.10" 64 }, 65 "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a" 66 }
··· 10 "build": "vite build", 11 "serve": "vite preview" 12 }, 13 "devDependencies": { 14 + "@iconify-json/lucide": "^1.2.87", 15 "@iconify/tailwind4": "^1.2.1", 16 "@tailwindcss/vite": "^4.1.18", 17 + "prettier": "^3.8.1", 18 "prettier-plugin-organize-imports": "^4.3.0", 19 "prettier-plugin-tailwindcss": "^0.7.2", 20 "tailwindcss": "^4.1.18", 21 22 23 24 + }, 25 + "dependencies": { 26 + "@atcute/atproto": "^3.1.10", 27 + "@atcute/bluesky": "^3.2.16", 28 + "@atcute/car": "^5.1.0", 29 + "@atcute/cbor": "^2.3.0", 30 + "@atcute/cid": "^2.4.0", 31 + 32 + 33 + "@atcute/did-plc": "^0.3.1", 34 + "@atcute/identity": "^1.1.3", 35 + "@atcute/identity-resolver": "^1.2.2", 36 + "@atcute/lexicon-doc": "^2.1.0", 37 + "@atcute/lexicon-resolver": "^0.1.6", 38 + "@atcute/lexicons": "^1.2.7", 39 + "@atcute/multibase": "^1.1.7", 40 + "@atcute/oauth-browser-client": "^3.0.0", 41 + "@atcute/repo": "^0.1.1", 42 + "@atcute/tangled": "^1.0.15", 43 + "@atcute/tid": "^1.1.1", 44 + "@codemirror/commands": "^6.10.1", 45 + "@codemirror/lang-json": "^6.0.2", 46 + "@codemirror/lint": "^6.9.3", 47 + "@codemirror/state": "^6.5.4", 48 + "@codemirror/view": "^6.39.12", 49 + "@fsegurai/codemirror-theme-basic-dark": "^6.2.3", 50 + "@fsegurai/codemirror-theme-basic-light": "^6.2.3", 51 + "@mary/exif-rm": "jsr:^0.2.2", 52 53 54 55 "@solidjs/router": "^0.15.4", 56 "codemirror": "^6.0.2", 57 "native-file-system-adapter": "^3.0.1", 58 + "solid-js": "^1.9.11" 59 }, 60 "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a" 61 }
+3 -1
src/components/button.tsx
··· 6 class?: string; 7 classList?: Record<string, boolean | undefined>; 8 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 9 children?: JSX.Element; 10 } 11 ··· 16 disabled={props.disabled ?? false} 17 class={ 18 props.class ?? 19 - "dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-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" 20 } 21 classList={props.classList} 22 onClick={props.onClick} 23 > 24 {props.children} 25 </button>
··· 6 class?: string; 7 classList?: Record<string, boolean | undefined>; 8 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 9 + ontouchstart?: (e: TouchEvent) => void; 10 children?: JSX.Element; 11 } 12 ··· 17 disabled={props.disabled ?? false} 18 class={ 19 props.class ?? 20 + "dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 flex items-center gap-1 rounded-md border border-neutral-200 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 transition-colors select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:text-neutral-300" 21 } 22 classList={props.classList} 23 onClick={props.onClick} 24 + ontouchstart={props.ontouchstart} 25 > 26 {props.children} 27 </button>
+20 -12
src/views/pds.tsx
··· 191 192 193 194 - 195 - 196 - 197 - 198 - 199 - 200 - 201 202 203 ··· 253 <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 pt-2 pb-4"> 254 <div class="flex flex-col items-center gap-1 pb-2"> 255 <p>{repos()?.length} loaded</p> 256 - <Show when={!response.loading && cursor()}> 257 - <Button onClick={() => refetch()}>Load more</Button> 258 - </Show> 259 - <Show when={response.loading}> 260 - <span class="iconify lucide--loader-circle animate-spin py-3.5 text-xl"></span> 261 </Show> 262 </div> 263 </div>
··· 191 192 193 194 + </div> 195 + <Show when={server().phoneVerificationRequired}> 196 + <div class="flex items-center gap-1"> 197 + <span class="font-semibold">Captcha Verification Required</span> 198 + <span class="iconify lucide--check text-green-500 dark:text-green-400"></span> 199 + </div> 200 + </Show> 201 202 203 ··· 253 <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 pt-2 pb-4"> 254 <div class="flex flex-col items-center gap-1 pb-2"> 255 <p>{repos()?.length} loaded</p> 256 + <Show when={cursor()}> 257 + <Button 258 + onClick={() => refetch()} 259 + disabled={response.loading} 260 + classList={{ "w-20 justify-center": true }} 261 + > 262 + <Show 263 + when={!response.loading} 264 + fallback={<span class="iconify lucide--loader-circle animate-spin text-base" />} 265 + > 266 + Load more 267 + </Show> 268 + </Button> 269 </Show> 270 </div> 271 </div>
+9 -8
src/auth/account.tsx
··· 1 import { Did } from "@atcute/lexicons"; 2 import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 import { A } from "@solidjs/router"; 4 - import { createEffect, createSignal, For, onMount, Show } from "solid-js"; 5 import { createStore, produce } from "solid-js/store"; 6 import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7 import { Modal } from "../components/modal.jsx"; ··· 26 setOpenManager, 27 setPendingPermissionEdit, 28 setSessions, 29 } from "./state.js"; 30 31 const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { ··· 72 73 export const AccountManager = () => { 74 const [avatars, setAvatars] = createStore<Record<Did, string>>(); 75 - const [showingAddAccount, setShowingAddAccount] = createSignal(false); 76 77 const getThumbnailUrl = (avatarUrl: string) => { 78 return avatarUrl.replace("img/avatar/", "img/avatar_thumbnail/"); ··· 122 open={openManager()} 123 onClose={() => { 124 setOpenManager(false); 125 - setShowingAddAccount(false); 126 scopeFlow.cancel(); 127 }} 128 alignTop 129 contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-full max-w-sm rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 mx-3 shadow-md dark:border-neutral-700" 130 > 131 - <Show when={!scopeFlow.showScopeSelector() && !showingAddAccount()}> 132 <div class="mb-2 px-1 font-semibold"> 133 <span>Switch account</span> 134 </div> ··· 169 </For> 170 </div> 171 <button 172 - onclick={() => setShowingAddAccount(true)} 173 class="dark:hover:bg-dark-200 dark:active:bg-dark-100 flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 174 > 175 <span class="iconify lucide--plus"></span> ··· 177 </button> 178 </Show> 179 180 - <Show when={showingAddAccount() && !scopeFlow.showScopeSelector()}> 181 - <Login onCancel={() => setShowingAddAccount(false)} /> 182 </Show> 183 184 <Show when={scopeFlow.showScopeSelector()}> ··· 189 onConfirm={scopeFlow.complete} 190 onCancel={() => { 191 scopeFlow.cancel(); 192 - setShowingAddAccount(false); 193 }} 194 /> 195 </Show>
··· 1 import { Did } from "@atcute/lexicons"; 2 import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 import { A } from "@solidjs/router"; 4 + import { createEffect, For, onMount, Show } from "solid-js"; 5 import { createStore, produce } from "solid-js/store"; 6 import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7 import { Modal } from "../components/modal.jsx"; ··· 26 setOpenManager, 27 setPendingPermissionEdit, 28 setSessions, 29 + setShowAddAccount, 30 + showAddAccount, 31 } from "./state.js"; 32 33 const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { ··· 74 75 export const AccountManager = () => { 76 const [avatars, setAvatars] = createStore<Record<Did, string>>(); 77 78 const getThumbnailUrl = (avatarUrl: string) => { 79 return avatarUrl.replace("img/avatar/", "img/avatar_thumbnail/"); ··· 123 open={openManager()} 124 onClose={() => { 125 setOpenManager(false); 126 + setShowAddAccount(false); 127 scopeFlow.cancel(); 128 }} 129 alignTop 130 contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-full max-w-sm rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 mx-3 shadow-md dark:border-neutral-700" 131 > 132 + <Show when={!scopeFlow.showScopeSelector() && !showAddAccount()}> 133 <div class="mb-2 px-1 font-semibold"> 134 <span>Switch account</span> 135 </div> ··· 170 </For> 171 </div> 172 <button 173 + onclick={() => setShowAddAccount(true)} 174 class="dark:hover:bg-dark-200 dark:active:bg-dark-100 flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 175 > 176 <span class="iconify lucide--plus"></span> ··· 178 </button> 179 </Show> 180 181 + <Show when={showAddAccount() && !scopeFlow.showScopeSelector()}> 182 + <Login onCancel={() => setShowAddAccount(false)} /> 183 </Show> 184 185 <Show when={scopeFlow.showScopeSelector()}> ··· 190 onConfirm={scopeFlow.complete} 191 onCancel={() => { 192 scopeFlow.cancel(); 193 + setShowAddAccount(false); 194 }} 195 /> 196 </Show>
+17 -17
src/components/search.tsx
··· 7 8 9 10 11 12 ··· 89 90 91 92 - 93 - 94 - 95 - 96 - 97 - 98 - 99 - 100 - 101 - 102 - 103 - 104 - 105 - 106 107 108 ··· 348 </span> 349 <button 350 type="button" 351 - class="text-xs text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200" 352 onClick={() => { 353 localStorage.removeItem(RECENT_SEARCHES_KEY); 354 setRecentSearches([]); ··· 390 </A> 391 <button 392 type="button" 393 - class="mr-1 flex items-center rounded p-1 opacity-0 group-hover:opacity-100 hover:bg-neutral-300 dark:hover:bg-neutral-600" 394 onClick={() => { 395 removeRecentSearch(recent.path); 396 setRecentSearches(getRecentSearches()); 397 }} 398 > 399 - <span class="iconify lucide--x text-sm text-neutral-500 dark:text-neutral-400"></span> 400 </button> 401 </div> 402 );
··· 7 8 9 10 + onMount, 11 + Show, 12 + } from "solid-js"; 13 + import { canHover } from "../layout"; 14 + import { resolveLexiconAuthority, resolveLexiconAuthorityDirect } from "../utils/api"; 15 + import { appHandleLink, appList, AppUrl } from "../utils/app-urls"; 16 + import { createDebouncedValue } from "../utils/hooks/debounced"; 17 18 19 ··· 96 97 98 99 + <Button onClick={() => setShowSearch(!showSearch())}> 100 + <span class="iconify lucide--search"></span> 101 + <span>Search</span> 102 + <Show when={canHover}> 103 + <kbd class="font-sans text-neutral-400 dark:text-neutral-500"> 104 + {/Mac/i.test(navigator.platform) ? "โŒ˜" : "โŒƒ"}K 105 + </kbd> 106 107 108 ··· 348 </span> 349 <button 350 type="button" 351 + class="text-xs not-hover:text-neutral-500 dark:not-hover:text-neutral-400" 352 onClick={() => { 353 localStorage.removeItem(RECENT_SEARCHES_KEY); 354 setRecentSearches([]); ··· 390 </A> 391 <button 392 type="button" 393 + class="flex items-center p-2.5 opacity-0 not-hover:text-neutral-500 group-hover:opacity-100 dark:not-hover:text-neutral-400" 394 onClick={() => { 395 removeRecentSearch(recent.path); 396 setRecentSearches(getRecentSearches()); 397 }} 398 > 399 + <span class="iconify lucide--x text-base"></span> 400 </button> 401 </div> 402 );
+11
src/workers/plc-validate.ts
···
··· 1 + import { processIndexedEntryLog } from "@atcute/did-plc"; 2 + 3 + self.onmessage = async (e: MessageEvent<{ did: string; logs: any }>) => { 4 + const { did, logs } = e.data; 5 + try { 6 + await processIndexedEntryLog(did as any, logs); 7 + self.postMessage({ valid: true }); 8 + } catch { 9 + self.postMessage({ valid: false }); 10 + } 11 + };
+235 -7
src/views/repo.tsx
··· 26 27 28 29 30 31 ··· 409 410 411 412 413 414 ··· 496 497 498 499 500 501 ··· 512 513 514 515 - } 516 - > 517 - <button 518 - class="-ml-1 flex w-fit items-center gap-1 rounded px-1 py-0.5 text-sm text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-700" 519 - onClick={async () => { 520 - setHandleDetailedResult(null); 521 - setHandleModalAlias(alias);
··· 26 27 28 29 + removeNotification, 30 + updateNotification, 31 + } from "../components/notification.jsx"; 32 + import { canHover } from "../layout.jsx"; 33 + import { 34 + didDocCache, 35 + type HandleResolveResult, 36 37 38 ··· 416 417 418 419 + </ErrorBoundary> 420 + </Show> 421 + <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 422 + <div class={`flex flex-col ${canHover ? "pb-16" : "pb-12"} text-sm wrap-anywhere`}> 423 + <Show 424 + when={Object.keys(nsids() ?? {}).length != 0} 425 + fallback={<span class="mt-3 text-center text-base">No collections found.</span>} 426 427 428 ··· 510 511 512 513 + } 514 + > 515 + <button 516 + class="-ml-1 flex w-fit items-center gap-1 rounded px-1 py-0.5 text-sm text-neutral-700 hover:bg-neutral-200 active:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-700" 517 + onClick={async () => { 518 + setHandleDetailedResult(null); 519 + setHandleModalAlias(alias); 520 521 522 ··· 533 534 535 536 + 537 + 538 + 539 + 540 + 541 + 542 + 543 + 544 + 545 + 546 + 547 + 548 + 549 + 550 + 551 + 552 + 553 + 554 + 555 + 556 + 557 + 558 + 559 + 560 + 561 + 562 + 563 + 564 + 565 + 566 + 567 + 568 + 569 + 570 + 571 + 572 + 573 + 574 + 575 + 576 + 577 + 578 + 579 + 580 + 581 + 582 + 583 + 584 + 585 + 586 + 587 + 588 + 589 + 590 + 591 + 592 + 593 + 594 + 595 + 596 + 597 + 598 + 599 + 600 + 601 + 602 + 603 + 604 + 605 + 606 + 607 + 608 + 609 + 610 + 611 + 612 + 613 + 614 + 615 + 616 + 617 + 618 + 619 + 620 + 621 + 622 + 623 + 624 + 625 + 626 + 627 + 628 + 629 + 630 + 631 + 632 + 633 + 634 + 635 + 636 + 637 + 638 + 639 + 640 + 641 + 642 + 643 + 644 + 645 + 646 + 647 + 648 + 649 + 650 + 651 + 652 + 653 + 654 + 655 + 656 + 657 + 658 + 659 + 660 + 661 + 662 + 663 + 664 + 665 + 666 + 667 + 668 + 669 + 670 + 671 + 672 + 673 + 674 + 675 + 676 + 677 + 678 + 679 + 680 + 681 + 682 + 683 + 684 + 685 + 686 + 687 + 688 + 689 + 690 + 691 + 692 + 693 + 694 + 695 + 696 + 697 + 698 + 699 + 700 + 701 + 702 + 703 + 704 + 705 + 706 + 707 + 708 + 709 + 710 + 711 + 712 + 713 + 714 + 715 + 716 + 717 + 718 + 719 + 720 + 721 + 722 + 723 + 724 + 725 + 726 + 727 + 728 + 729 + 730 + 731 + 732 + 733 + 734 + 735 + 736 + 737 + 738 + 739 + 740 + 741 + 742 + 743 + </Show> 744 + 745 + <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 746 + <div class={`fixed ${canHover ? "bottom-12" : "bottom-8"} z-10 w-full max-w-lg`}> 747 + <div 748 + class="dark:bg-dark-200 dark:shadow-dark-700 mx-3 flex cursor-text items-center gap-2 rounded-lg border border-neutral-200 bg-white px-3 shadow-sm dark:border-neutral-700" 749 + onClick={(e) => {
+30 -24
src/components/navbar.tsx
··· 1 2 3 4 5 6 7 ··· 92 93 94 95 - 96 - 97 - 98 - 99 - 100 - 101 - 102 - 103 - 104 - 105 - 106 - 107 - 108 - 109 - 110 - 111 - 112 - 113 - 114 - 115 - 116 - 117 - 118 119 120 ··· 132 <Show when={hasHoveredRepo() && isCustomDomain()}> 133 <img 134 src={`https://${handle()}/favicon.ico`} 135 - class="size-4 rounded-full" 136 classList={{ hidden: !repoHovered() || !faviconLoaded() }} 137 onLoad={() => setFaviconLoaded(true)} 138 onError={() => setFaviconLoaded(false)}
··· 1 + import * as TID from "@atcute/tid"; 2 + import { A, Params } from "@solidjs/router"; 3 + import { createEffect, createMemo, createSignal, Show } from "solid-js"; 4 + import { canHover } from "../layout"; 5 + import { didDocCache } from "../utils/api"; 6 + import { addToClipboard } from "../utils/copy"; 7 + import { localDateFromTimestamp } from "../utils/date"; 8 9 10 11 12 + const CopyButton = (props: { content: string; label: string }) => { 13 + return ( 14 + <Show when={canHover}> 15 + <Tooltip text={props.label}> 16 + <button 17 + type="button" 18 19 20 ··· 105 106 107 108 + <div 109 + class="group relative flex items-center justify-between gap-1 rounded-md border-[0.5px] border-transparent bg-transparent px-2 transition-all duration-200 hover:border-neutral-300 hover:bg-neutral-50/40 dark:hover:border-neutral-600 dark:hover:bg-neutral-800/40" 110 + onMouseEnter={() => { 111 + if (canHover) { 112 + setRepoHovered(true); 113 + setHasHoveredRepo(true); 114 + } 115 + }} 116 + onMouseLeave={() => { 117 + if (canHover) { 118 + setRepoHovered(false); 119 + } 120 + }} 121 + > 122 + <div class="flex min-w-0 basis-full items-center gap-2"> 123 + <Tooltip text="Repository"> 124 125 126 ··· 138 <Show when={hasHoveredRepo() && isCustomDomain()}> 139 <img 140 src={`https://${handle()}/favicon.ico`} 141 + class="size-4" 142 classList={{ hidden: !repoHovered() || !faviconLoaded() }} 143 onLoad={() => setFaviconLoaded(true)} 144 onError={() => setFaviconLoaded(false)}
public/avatar/drunk.moe.jpg

This is a binary file and will not be displayed.

public/avatar/slug.moe.jpg

This is a binary file and will not be displayed.

public/avatar/notnite.com.jpg

This is a binary file and will not be displayed.

public/avatar/neko.moe.observer.jpg

This is a binary file and will not be displayed.

public/avatar/nel.pet.jpg

This is a binary file and will not be displayed.

public/avatar/shi.gg.jpg

This is a binary file and will not be displayed.