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