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};