atproto explorer pdsls.dev
atproto tool
at main 12 kB view raw
1import { ComAtprotoLabelDefs } from "@atcute/atproto"; 2import { Client, simpleFetchHandler } from "@atcute/client"; 3import { isAtprotoDid } from "@atcute/identity"; 4import { Handle } from "@atcute/lexicons"; 5import { A, useSearchParams } from "@solidjs/router"; 6import { createMemo, createSignal, For, onMount, Show } from "solid-js"; 7import { Button } from "../components/button.jsx"; 8import { StickyOverlay } from "../components/sticky.jsx"; 9import { TextInput } from "../components/text-input.jsx"; 10import { labelerCache, resolveHandle, resolvePDS } from "../utils/api.js"; 11import { localDateFromTimestamp } from "../utils/date.js"; 12 13const LABELS_PER_PAGE = 50; 14 15const LabelCard = (props: { label: ComAtprotoLabelDefs.Label }) => { 16 const label = props.label; 17 18 return ( 19 <div class="flex flex-col gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-3 dark:border-neutral-700 dark:bg-neutral-800"> 20 <div class="flex gap-1 text-sm"> 21 <span class="iconify lucide--tag shrink-0 self-center" /> 22 <div class="flex flex-wrap items-baseline gap-2"> 23 <span class="font-medium">{label.val}</span> 24 <Show when={label.neg}> 25 <span class="text-xs font-medium text-red-500 dark:text-red-400">negated</span> 26 </Show> 27 <div class="flex flex-wrap gap-2 text-xs text-neutral-600 dark:text-neutral-400"> 28 <span>{localDateFromTimestamp(new Date(label.cts).getTime())}</span> 29 <Show when={label.exp}> 30 {(exp) => ( 31 <div class="flex items-center gap-x-1"> 32 <span class="iconify lucide--clock-fading shrink-0" /> 33 <span>{localDateFromTimestamp(new Date(exp()).getTime())}</span> 34 </div> 35 )} 36 </Show> 37 </div> 38 </div> 39 </div> 40 41 <A 42 href={`/at://${label.uri.replace("at://", "")}`} 43 class="text-sm break-all text-blue-600 hover:underline dark:text-blue-400" 44 > 45 {label.uri} 46 </A> 47 48 <Show when={label.cid}> 49 <div class="font-mono text-xs break-all text-neutral-700 dark:text-neutral-300"> 50 {label.cid} 51 </div> 52 </Show> 53 </div> 54 ); 55}; 56 57export const LabelView = () => { 58 const [searchParams, setSearchParams] = useSearchParams(); 59 const [cursor, setCursor] = createSignal<string>(); 60 const [labels, setLabels] = createSignal<ComAtprotoLabelDefs.Label[]>([]); 61 const [filter, setFilter] = createSignal(""); 62 const [loading, setLoading] = createSignal(false); 63 const [error, setError] = createSignal<string>(); 64 const [didInput, setDidInput] = createSignal(searchParams.did ?? ""); 65 66 let rpc: Client | undefined; 67 let formRef!: HTMLFormElement; 68 69 const filteredLabels = createMemo(() => { 70 const filterValue = filter().trim(); 71 if (!filterValue) return labels(); 72 73 const filters = filterValue 74 .split(/[\s,]+/) 75 .map((f) => f.trim()) 76 .filter((f) => f.length > 0); 77 78 const exclusions: { pattern: string; hasWildcard: boolean }[] = []; 79 const inclusions: { pattern: string; hasWildcard: boolean }[] = []; 80 81 filters.forEach((f) => { 82 if (f.startsWith("-")) { 83 const lower = f.slice(1).toLowerCase(); 84 exclusions.push({ 85 pattern: lower, 86 hasWildcard: lower.includes("*"), 87 }); 88 } else { 89 const lower = f.toLowerCase(); 90 inclusions.push({ 91 pattern: lower, 92 hasWildcard: lower.includes("*"), 93 }); 94 } 95 }); 96 97 const matchesPattern = (value: string, filter: { pattern: string; hasWildcard: boolean }) => { 98 if (filter.hasWildcard) { 99 // Convert wildcard pattern to regex 100 const regexPattern = filter.pattern 101 .replace(/[.+?^${}()|[\]\\]/g, "\\$&") // Escape special regex chars except * 102 .replace(/\*/g, ".*"); // Replace * with .* 103 const regex = new RegExp(`^${regexPattern}$`); 104 return regex.test(value); 105 } else { 106 return value === filter.pattern; 107 } 108 }; 109 110 return labels().filter((label) => { 111 const labelValue = label.val.toLowerCase(); 112 113 if (exclusions.some((exc) => matchesPattern(labelValue, exc))) { 114 return false; 115 } 116 117 // If there are inclusions, at least one must match 118 if (inclusions.length > 0) { 119 return inclusions.some((inc) => matchesPattern(labelValue, inc)); 120 } 121 122 // If only exclusions were specified, include everything not excluded 123 return true; 124 }); 125 }); 126 127 const hasSearched = createMemo(() => Boolean(searchParams.uriPatterns)); 128 129 onMount(async () => { 130 if (searchParams.did && searchParams.uriPatterns) { 131 const formData = new FormData(); 132 formData.append("did", searchParams.did.toString()); 133 formData.append("uriPatterns", searchParams.uriPatterns.toString()); 134 await fetchLabels(formData); 135 } 136 }); 137 138 const fetchLabels = async (formData: FormData, reset?: boolean) => { 139 let did = formData.get("did")?.toString()?.trim(); 140 const uriPatterns = formData.get("uriPatterns")?.toString()?.trim(); 141 142 if (!did || !uriPatterns) { 143 setError("Please provide both DID and URI patterns"); 144 return; 145 } 146 147 if (reset) { 148 setLabels([]); 149 setCursor(undefined); 150 setError(undefined); 151 } 152 153 try { 154 setLoading(true); 155 setError(undefined); 156 157 if (!isAtprotoDid(did)) did = await resolveHandle(did as Handle); 158 await resolvePDS(did); 159 if (!labelerCache[did]) throw new Error("Repository is not a labeler"); 160 rpc = new Client({ 161 handler: simpleFetchHandler({ service: labelerCache[did] }), 162 }); 163 164 setSearchParams({ did, uriPatterns }); 165 setDidInput(did); 166 167 const res = await rpc.get("com.atproto.label.queryLabels", { 168 params: { 169 uriPatterns: uriPatterns.split(",").map((p) => p.trim()), 170 sources: [did as `did:${string}:${string}`], 171 cursor: cursor(), 172 }, 173 }); 174 175 if (!res.ok) throw new Error(res.data.error || "Failed to fetch labels"); 176 177 const newLabels = res.data.labels || []; 178 setCursor(newLabels.length < LABELS_PER_PAGE ? undefined : res.data.cursor); 179 setLabels(reset ? newLabels : [...labels(), ...newLabels]); 180 } catch (err) { 181 setError(err instanceof Error ? err.message : "An error occurred"); 182 console.error("Failed to fetch labels:", err); 183 } finally { 184 setLoading(false); 185 } 186 }; 187 188 const handleSearch = () => { 189 fetchLabels(new FormData(formRef), true); 190 }; 191 192 const handleLoadMore = () => { 193 fetchLabels(new FormData(formRef)); 194 }; 195 196 return ( 197 <div class="flex w-full flex-col items-center"> 198 <form 199 ref={formRef} 200 class="flex w-full max-w-3xl flex-col gap-y-2 px-3 pb-2" 201 onSubmit={(e) => { 202 e.preventDefault(); 203 handleSearch(); 204 }} 205 > 206 <div class="flex flex-col gap-y-1.5"> 207 <label class="flex w-full flex-col gap-y-1"> 208 <span class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 209 Labeler DID/Handle 210 </span> 211 <TextInput 212 name="did" 213 value={didInput()} 214 onInput={(e) => setDidInput(e.currentTarget.value)} 215 placeholder="did:plc:..." 216 class="w-full" 217 /> 218 </label> 219 220 <label class="flex w-full flex-col gap-y-1"> 221 <span class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 222 URI Patterns (comma-separated) 223 </span> 224 <textarea 225 id="uriPatterns" 226 name="uriPatterns" 227 spellcheck={false} 228 rows={2} 229 value={searchParams.uriPatterns ?? "*"} 230 placeholder="at://did:web:example.com/app.bsky.feed.post/*" 231 class="dark:bg-dark-100 dark:inset-shadow-dark-200 grow rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 py-1.5 text-sm inset-shadow-xs focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400" 232 /> 233 </label> 234 </div> 235 236 <Button 237 type="submit" 238 disabled={loading()} 239 class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 w-fit items-center justify-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" 240 > 241 <span class="iconify lucide--search" /> 242 <span>Search Labels</span> 243 </Button> 244 245 <Show when={error()}> 246 <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"> 247 {error()} 248 </div> 249 </Show> 250 </form> 251 252 <Show when={hasSearched()}> 253 <StickyOverlay> 254 <div class="flex w-full items-center gap-x-2"> 255 <TextInput 256 placeholder="Filter labels (* for partial, -exclude)" 257 name="filter" 258 value={filter()} 259 onInput={(e) => setFilter(e.currentTarget.value)} 260 class="min-w-0 grow text-sm placeholder:text-xs" 261 /> 262 <div class="flex shrink-0 items-center gap-x-2 text-sm"> 263 <Show when={labels().length > 0}> 264 <span class="whitespace-nowrap text-neutral-600 dark:text-neutral-400"> 265 {filteredLabels().length}/{labels().length} 266 </span> 267 </Show> 268 269 <Show when={cursor()}> 270 <Button 271 onClick={handleLoadMore} 272 disabled={loading()} 273 class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 w-20 items-center justify-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" 274 > 275 <Show 276 when={!loading()} 277 fallback={<span class="iconify lucide--loader-circle animate-spin" />} 278 > 279 Load More 280 </Show> 281 </Button> 282 </Show> 283 </div> 284 </div> 285 </StickyOverlay> 286 287 <div class="w-full max-w-3xl px-3 py-2"> 288 <Show when={loading() && labels().length === 0}> 289 <div class="flex flex-col items-center justify-center py-12 text-center"> 290 <span class="iconify lucide--loader-circle mb-3 animate-spin text-4xl text-neutral-400" /> 291 <p class="text-sm text-neutral-600 dark:text-neutral-400">Loading labels...</p> 292 </div> 293 </Show> 294 295 <Show when={!loading() || labels().length > 0}> 296 <Show when={filteredLabels().length > 0}> 297 <div class="grid gap-2"> 298 <For each={filteredLabels()}>{(label) => <LabelCard label={label} />}</For> 299 </div> 300 </Show> 301 302 <Show when={labels().length > 0 && filteredLabels().length === 0}> 303 <div class="flex flex-col items-center justify-center py-8 text-center"> 304 <span class="iconify lucide--search-x mb-2 text-3xl text-neutral-400" /> 305 <p class="text-sm text-neutral-600 dark:text-neutral-400"> 306 No labels match your filter 307 </p> 308 </div> 309 </Show> 310 311 <Show when={labels().length === 0 && !loading()}> 312 <div class="flex flex-col items-center justify-center py-8 text-center"> 313 <span class="iconify lucide--tags mb-2 text-3xl text-neutral-400" /> 314 <p class="text-sm text-neutral-600 dark:text-neutral-400">No labels found</p> 315 </div> 316 </Show> 317 </Show> 318 </div> 319 </Show> 320 </div> 321 ); 322};