atproto explorer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 157 lines 5.5 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"; 6 7type BacklinksProps = { 8 target: string; 9 collection: string; 10 path: string; 11}; 12 13type BacklinkEntry = { 14 collection: string; 15 path: string; 16 counts: { distinct_dids: number; records: number }; 17}; 18 19const flattenLinks = (links: Record<string, any>): BacklinkEntry[] => { 20 const entries: BacklinkEntry[] = []; 21 Object.keys(links) 22 .toSorted() 23 .forEach((collection) => { 24 const paths = links[collection]; 25 Object.keys(paths) 26 .toSorted() 27 .forEach((path) => { 28 if (paths[path].records > 0) { 29 entries.push({ collection, path, counts: paths[path] }); 30 } 31 }); 32 }); 33 return entries; 34}; 35 36const BacklinkRecords = (props: BacklinksProps & { cursor?: string }) => { 37 const [links, setLinks] = createSignal<LinksWithRecords>(); 38 const [more, setMore] = createSignal(false); 39 40 onMount(async () => { 41 const res = await getRecordBacklinks(props.target, props.collection, props.path, props.cursor); 42 setLinks(res); 43 }); 44 45 return ( 46 <Show when={links()} fallback={<p class="px-3 py-2 text-neutral-500">Loading</p>}> 47 <For each={links()!.linking_records}> 48 {({ did, collection, rkey }) => { 49 const timestamp = 50 TID.validate(rkey) ? localDateFromTimestamp(TID.parse(rkey).timestamp / 1000) : null; 51 return ( 52 <a 53 href={`/at://${did}/${collection}/${rkey}`} 54 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 active:bg-neutral-200/50 sm:gap-x-3 sm:px-3 dark:hover:bg-neutral-700/50 dark:active:bg-neutral-700/50" 55 > 56 <span class="text-blue-500 dark:text-blue-400">{rkey}</span> 57 <span class="truncate text-neutral-700 dark:text-neutral-300" title={did}> 58 {did} 59 </span> 60 <span class="text-neutral-500 tabular-nums dark:text-neutral-400"> 61 {timestamp ?? ""} 62 </span> 63 </a> 64 ); 65 }} 66 </For> 67 <Show when={links()?.cursor}> 68 <Show 69 when={more()} 70 fallback={ 71 <div class="p-2"> 72 <Button 73 onClick={() => setMore(true)} 74 class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 w-full items-center justify-center gap-1 rounded border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 75 > 76 Load More 77 </Button> 78 </div> 79 } 80 > 81 <BacklinkRecords 82 target={props.target} 83 collection={props.collection} 84 path={props.path} 85 cursor={links()!.cursor} 86 /> 87 </Show> 88 </Show> 89 </Show> 90 ); 91}; 92 93const Backlinks = (props: { target: string }) => { 94 const [response] = createResource(async () => { 95 const res = await getAllBacklinks(props.target); 96 return flattenLinks(res.links); 97 }); 98 99 return ( 100 <div class="flex w-full flex-col gap-3 text-sm"> 101 <Show when={response()} fallback={<p class="text-neutral-500">Loading</p>}> 102 <Show when={response()!.length === 0}> 103 <p class="text-neutral-500">No backlinks found.</p> 104 </Show> 105 <For each={response()}> 106 {(entry) => ( 107 <BacklinkSection 108 target={props.target} 109 collection={entry.collection} 110 path={entry.path} 111 counts={entry.counts} 112 /> 113 )} 114 </For> 115 </Show> 116 </div> 117 ); 118}; 119 120const BacklinkSection = ( 121 props: BacklinksProps & { counts: { distinct_dids: number; records: number } }, 122) => { 123 const [expanded, setExpanded] = createSignal(false); 124 125 return ( 126 <div class="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-700"> 127 <button 128 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" 129 onClick={() => setExpanded(!expanded())} 130 > 131 <div class="flex min-w-0 flex-1 flex-col"> 132 <span class="w-full truncate">{props.collection}</span> 133 <span class="w-full text-xs wrap-break-word text-neutral-500 dark:text-neutral-400"> 134 {props.path.slice(1)} 135 </span> 136 </div> 137 <div class="flex shrink-0 items-center gap-2 text-neutral-700 dark:text-neutral-300"> 138 <span class="text-xs"> 139 {props.counts.records} from {props.counts.distinct_dids} repo 140 {props.counts.distinct_dids > 1 ? "s" : ""} 141 </span> 142 <span 143 class="iconify lucide--chevron-down transition-transform" 144 classList={{ "rotate-180": expanded() }} 145 /> 146 </div> 147 </button> 148 <Show when={expanded()}> 149 <div class="border-t border-neutral-200 bg-neutral-50/50 dark:border-neutral-700 dark:bg-neutral-800/30"> 150 <BacklinkRecords target={props.target} collection={props.collection} path={props.path} /> 151 </div> 152 </Show> 153 </div> 154 ); 155}; 156 157export { Backlinks };