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