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