atproto explorer pdsls.dev
atproto tool
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 60 title="Collection containing linking records" 61 class="iconify lucide--book-text shrink-0" 62 ></span> 63 {collection} 64 </div> 65 <For each={response()![collection]}> 66 {({ path, counts }) => ( 67 <div class="ml-4.5"> 68 <div class="flex items-center gap-1"> 69 <span 70 title="Record path where the link is found" 71 class="iconify lucide--route shrink-0" 72 ></span> 73 {path.slice(1)} 74 </div> 75 <div class="ml-4.5"> 76 <p> 77 <button 78 class="text-blue-400 hover:underline active:underline" 79 title="Show linking records" 80 onclick={() => 81 ( 82 show()?.collection === collection && 83 show()?.path === path && 84 !show()?.showDids 85 ) ? 86 setShow(null) 87 : setShow({ collection, path, showDids: false }) 88 } 89 > 90 {counts.records} record{counts.records < 2 ? "" : "s"} 91 </button> 92 {" from "} 93 <button 94 class="text-blue-400 hover:underline active:underline" 95 title="Show linking DIDs" 96 onclick={() => 97 ( 98 show()?.collection === collection && 99 show()?.path === path && 100 show()?.showDids 101 ) ? 102 setShow(null) 103 : setShow({ collection, path, showDids: true }) 104 } 105 > 106 {counts.distinct_dids} DID 107 {counts.distinct_dids < 2 ? "" : "s"} 108 </button> 109 </p> 110 <Show when={show()?.collection === collection && show()?.path === path}> 111 <Show when={show()?.showDids}> 112 <p class="w-full font-semibold">Distinct identities</p> 113 <BacklinkItems 114 target={props.target} 115 collection={collection} 116 path={path} 117 dids={true} 118 /> 119 </Show> 120 <Show when={!show()?.showDids}> 121 <p class="w-full font-semibold">Records</p> 122 <BacklinkItems 123 target={props.target} 124 collection={collection} 125 path={path} 126 dids={false} 127 /> 128 </Show> 129 </Show> 130 </div> 131 </div> 132 )} 133 </For> 134 </div> 135 )} 136 </For> 137 </Show> 138 </div> 139 ); 140}; 141 142// switching on !!did everywhere is pretty annoying, this could probably be two components 143// but i don't want to duplicate or think about how to extract the paging logic 144const BacklinkItems = ({ 145 target, 146 collection, 147 path, 148 dids, 149 cursor, 150}: { 151 target: string; 152 collection: string; 153 path: string; 154 dids: boolean; 155 cursor?: string; 156}) => { 157 const [links, setLinks] = createSignal<LinksWithDids | LinksWithRecords>(); 158 const [more, setMore] = createSignal<boolean>(false); 159 160 onMount(async () => { 161 const links = await (dids ? getDidBacklinks : getRecordBacklinks)( 162 target, 163 collection, 164 path, 165 cursor, 166 ); 167 setLinks(links); 168 }); 169 170 // TODO: could pass the `total` into this component, which can be checked against each call to this endpoint to find if it's stale. 171 // also hmm 'total' is misleading/wrong on that api 172 173 return ( 174 <Show when={links()} fallback={<p>Loading&hellip;</p>}> 175 <Show when={dids}> 176 <For each={(links() as LinksWithDids).linking_dids}> 177 {(did) => ( 178 <a 179 href={`/at://${did}`} 180 class="relative flex w-full font-mono text-blue-400 hover:underline active:underline" 181 > 182 {did} 183 </a> 184 )} 185 </For> 186 </Show> 187 <Show when={!dids}> 188 <For each={(links() as LinksWithRecords).linking_records}> 189 {({ did, collection, rkey }) => ( 190 <p class="relative flex w-full items-center gap-1 font-mono"> 191 <a 192 href={`/at://${did}/${collection}/${rkey}`} 193 class="text-blue-400 hover:underline active:underline" 194 > 195 {rkey} 196 </a> 197 <span class="text-xs text-neutral-500 dark:text-neutral-400"> 198 {TID.validate(rkey) ? 199 localDateFromTimestamp(TID.parse(rkey).timestamp / 1000) 200 : undefined} 201 </span> 202 </p> 203 )} 204 </For> 205 </Show> 206 <Show when={links()?.cursor}> 207 <Show when={more()} fallback={<Button onClick={() => setMore(true)}>Load More</Button>}> 208 <BacklinkItems 209 target={target} 210 collection={collection} 211 path={path} 212 dids={dids} 213 cursor={links()!.cursor} 214 /> 215 </Show> 216 </Show> 217 </Show> 218 ); 219}; 220 221export { Backlinks };