forked from
pds.ls/pdsls
atmosphere explorer
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};