atproto explorer pdsls.dev
atproto tool
at main 7.2 kB view raw
1import * as TID from "@atcute/tid"; 2import { createResource, createSignal, For, onMount, Show } from "solid-js"; 3import { 4 getAllBacklinks, 5 getDidBacklinks, 6 getRecordBacklinks, 7 LinksWithDids, 8 LinksWithRecords, 9} from "../utils/api.js"; 10import { localDateFromTimestamp } from "../utils/date.js"; 11import { Button } from "./button.jsx"; 12 13type Backlink = { 14 path: string; 15 counts: { distinct_dids: number; records: number }; 16}; 17 18const linksBySource = (links: Record<string, any>) => { 19 let out: Record<string, Backlink[]> = {}; 20 Object.keys(links) 21 .toSorted() 22 .forEach((collection) => { 23 const paths = links[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 35const 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 136const 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 213export { Backlinks };