atmosphere explorer
at main 182 lines 6.3 kB view raw
1import * as TID from "@atcute/tid"; 2import { createResource, createSignal, For, onMount, Show } from "solid-js"; 3import { getAllBacklinks, getRecordBacklinks, LinksWithRecords } from "../utils/api.js"; 4import { localDateFromTimestamp } from "../utils/date.js"; 5import { Button } from "./button.jsx"; 6import { Favicon } from "./favicon.jsx"; 7import DidHoverCard from "./hover-card/did.jsx"; 8import RecordHoverCard from "./hover-card/record.jsx"; 9 10type BacklinksProps = { 11 target: string; 12 collection: string; 13 path: string; 14}; 15 16type BacklinkEntry = { 17 collection: string; 18 path: string; 19 counts: { distinct_dids: number; records: number }; 20}; 21 22const flattenLinks = (links: Record<string, any>): BacklinkEntry[] => { 23 const entries: BacklinkEntry[] = []; 24 Object.keys(links) 25 .toSorted() 26 .forEach((collection) => { 27 const paths = links[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 39const 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 113const 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 140const 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 182export { Backlinks };