tangled
alpha
login
or
join now
olaren.dev
/
pdsls
forked from
pds.ls/pdsls
0
fork
atom
atmosphere explorer
0
fork
atom
overview
issues
pulls
pipelines
recent search
handle.invalid
1 month ago
f25c0a53
c7b58b53
verified
This commit was signed with the committer's
known signature
.
handle.invalid
SSH Key Fingerprint:
SHA256:mBrT4x0JdzLpbVR95g1hjI1aaErfC02kmLRkPXwsYCk=
+196
-140
2 changed files
expand all
collapse all
unified
split
src
components
search.tsx
utils
app-urls.ts
+193
-131
src/components/search.tsx
···
16
16
import { createDebouncedValue } from "../utils/hooks/debounced";
17
17
import { Modal } from "./modal";
18
18
19
19
+
type RecentSearch = {
20
20
+
path: string;
21
21
+
label: string;
22
22
+
type: "handle" | "did" | "at-uri" | "lexicon" | "pds" | "url";
23
23
+
};
24
24
+
25
25
+
const RECENT_SEARCHES_KEY = "recent-searches";
26
26
+
const MAX_RECENT_SEARCHES = 5;
27
27
+
28
28
+
const getRecentSearches = (): RecentSearch[] => {
29
29
+
try {
30
30
+
const stored = localStorage.getItem(RECENT_SEARCHES_KEY);
31
31
+
return stored ? JSON.parse(stored) : [];
32
32
+
} catch {
33
33
+
return [];
34
34
+
}
35
35
+
};
36
36
+
37
37
+
const addRecentSearch = (search: RecentSearch) => {
38
38
+
const searches = getRecentSearches();
39
39
+
const filtered = searches.filter((s) => s.path !== search.path);
40
40
+
const updated = [search, ...filtered].slice(0, MAX_RECENT_SEARCHES);
41
41
+
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
42
42
+
};
43
43
+
44
44
+
const removeRecentSearch = (path: string) => {
45
45
+
const searches = getRecentSearches();
46
46
+
const updated = searches.filter((s) => s.path !== path);
47
47
+
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
48
48
+
};
49
49
+
19
50
export const [showSearch, setShowSearch] = createSignal(false);
20
51
21
52
const SEARCH_PREFIXES: { prefix: string; description: string }[] = [
···
37
68
return { prefix: null, query: input };
38
69
};
39
70
40
40
-
const SearchButton = () => {
71
71
+
export const SearchButton = () => {
41
72
onMount(() => window.addEventListener("keydown", keyEvent));
42
73
onCleanup(() => window.removeEventListener("keydown", keyEvent));
43
74
···
79
110
);
80
111
};
81
112
82
82
-
const Search = () => {
113
113
+
export const Search = () => {
83
114
const navigate = useNavigate();
84
115
let searchInput!: HTMLInputElement;
85
116
const rpc = new Client({
86
117
handler: simpleFetchHandler({ service: "https://public.api.bsky.app" }),
87
118
});
119
119
+
const [recentSearches, setRecentSearches] = createSignal<RecentSearch[]>(getRecentSearches());
88
120
89
121
onMount(() => {
90
122
const handlePaste = (e: ClipboardEvent) => {
···
136
168
fetchTypeahead,
137
169
);
138
170
139
139
-
const getPrefixSuggestions = () => {
140
140
-
const currentInput = input();
141
141
-
if (!currentInput) return SEARCH_PREFIXES;
171
171
+
const getRecentSuggestions = () => {
172
172
+
const currentInput = input()?.toLowerCase();
173
173
+
if (!currentInput) return recentSearches();
174
174
+
return recentSearches().filter((r) => r.label.toLowerCase().includes(currentInput));
175
175
+
};
142
176
143
143
-
const { prefix, query } = parsePrefix(currentInput);
144
144
-
if (prefix && query.length > 0) return [];
145
145
-
146
146
-
return SEARCH_PREFIXES.filter((p) => p.prefix.startsWith(currentInput.toLowerCase()));
177
177
+
const saveRecentSearch = (path: string, label: string, type: RecentSearch["type"]) => {
178
178
+
addRecentSearch({ path, label, type });
179
179
+
setRecentSearches(getRecentSearches());
147
180
};
148
181
149
182
const processInput = async (input: string) => {
···
161
194
const { prefix, query } = parsePrefix(input);
162
195
163
196
if (prefix === "@") {
164
164
-
navigate(`/at://${query}`);
197
197
+
const path = `/at://${query}`;
198
198
+
saveRecentSearch(path, query, "handle");
199
199
+
navigate(path);
165
200
} else if (prefix === "did:") {
166
166
-
navigate(`/at://did:${query}`);
201
201
+
const path = `/at://did:${query}`;
202
202
+
saveRecentSearch(path, `did:${query}`, "did");
203
203
+
navigate(path);
167
204
} else if (prefix === "at:") {
168
168
-
navigate(`/${input}`);
205
205
+
const path = `/${input}`;
206
206
+
saveRecentSearch(path, input, "at-uri");
207
207
+
navigate(path);
169
208
} else if (prefix === "lex:") {
170
209
if (query.split(".").length >= 3) {
171
210
const nsid = query as Nsid;
172
211
const res = await resolveLexiconAuthority(nsid);
173
173
-
navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`);
212
212
+
const path = `/at://${res}/com.atproto.lexicon.schema/${nsid}`;
213
213
+
saveRecentSearch(path, query, "lexicon");
214
214
+
navigate(path);
174
215
} else {
175
216
const did = await resolveLexiconAuthorityDirect(query);
176
176
-
navigate(`/at://${did}/com.atproto.lexicon.schema`);
217
217
+
const path = `/at://${did}/com.atproto.lexicon.schema`;
218
218
+
saveRecentSearch(path, query, "lexicon");
219
219
+
navigate(path);
177
220
}
178
221
} else if (prefix === "pds:") {
179
179
-
navigate(`/${query}`);
222
222
+
const path = `/${query}`;
223
223
+
saveRecentSearch(path, query, "pds");
224
224
+
navigate(path);
180
225
} else if (input.startsWith("https://") || input.startsWith("http://")) {
181
226
const hostLength = input.indexOf("/", 8);
182
227
const host = input.slice(0, hostLength).replace("https://", "").replace("http://", "");
183
228
184
229
if (!(host in appList)) {
185
185
-
navigate(`/${input.replace("https://", "").replace("http://", "").replace("/", "")}`);
230
230
+
const path = `/${input.replace("https://", "").replace("http://", "").replace("/", "")}`;
231
231
+
saveRecentSearch(path, input, "url");
232
232
+
navigate(path);
186
233
} else {
187
234
const app = appList[host as AppUrl];
188
188
-
const path = input.slice(hostLength + 1).split("/");
189
189
-
190
190
-
const uri = appHandleLink[app](path);
191
191
-
navigate(`/${uri}`);
235
235
+
const pathParts = input.slice(hostLength + 1).split("/");
236
236
+
const uri = appHandleLink[app](pathParts);
237
237
+
const path = `/${uri}`;
238
238
+
saveRecentSearch(path, input, "url");
239
239
+
navigate(path);
192
240
}
193
241
} else {
194
194
-
navigate(`/at://${input.replace("at://", "")}`);
242
242
+
const path = `/at://${input.replace("at://", "")}`;
243
243
+
const type = input.split("/").length > 1 ? "at-uri" : "handle";
244
244
+
saveRecentSearch(path, input, type);
245
245
+
navigate(path);
195
246
}
196
247
};
197
248
···
200
251
open={showSearch()}
201
252
onClose={() => setShowSearch(false)}
202
253
alignTop
203
203
-
contentClass="dark:bg-dark-200 dark:shadow-dark-700 pointer-events-auto mx-3 w-full max-w-lg rounded-lg border-[0.5px] border-neutral-300 bg-white shadow-md dark:border-neutral-700"
254
254
+
contentClass="dark:bg-dark-200 dark:shadow-dark-700 pointer-events-auto mx-3 w-full max-w-lg rounded-lg border-[0.5px] min-w-0 border-neutral-300 bg-white shadow-md dark:border-neutral-700"
204
255
>
205
256
<form
206
257
class="w-full"
···
210
261
}}
211
262
>
212
263
<label for="input" class="hidden">
213
213
-
PDS URL, AT URI, NSID, DID, or handle
264
264
+
Search or paste a link
214
265
</label>
215
266
<div
216
216
-
class={`flex items-center gap-2 px-2 ${
217
217
-
getPrefixSuggestions().length > 0 || search()?.length ? "rounded-t-lg" : "rounded-lg"
267
267
+
class={`flex items-center gap-2 px-3 ${
268
268
+
getRecentSuggestions().length > 0 || search()?.length ? "rounded-t-lg" : "rounded-lg"
218
269
}`}
219
270
>
220
271
<label
···
225
276
type="text"
226
277
spellcheck={false}
227
278
autocapitalize="off"
228
228
-
placeholder="Handle, DID, AT URI, NSID, PDS"
279
279
+
placeholder="Search or paste a link..."
229
280
ref={searchInput}
230
281
id="input"
231
282
class="grow py-2.5 select-none placeholder:text-sm focus:outline-none"
···
237
288
onBlur={() => setSelectedIndex(-1)}
238
289
onKeyDown={(e) => {
239
290
const results = search();
240
240
-
const prefixSuggestions = getPrefixSuggestions();
241
241
-
const totalSuggestions = (prefixSuggestions.length || 0) + (results?.length || 0);
291
291
+
const recent = getRecentSuggestions();
292
292
+
const totalSuggestions = recent.length + (results?.length || 0);
242
293
243
294
if (!totalSuggestions) return;
244
295
···
256
307
const index = selectedIndex();
257
308
if (index >= 0) {
258
309
e.preventDefault();
259
259
-
if (index < prefixSuggestions.length) {
260
260
-
const selectedPrefix = prefixSuggestions[index];
261
261
-
setInput(selectedPrefix.prefix);
262
262
-
setSelectedIndex(-1);
263
263
-
searchInput.focus();
310
310
+
if (index < recent.length) {
311
311
+
const item = recent[index];
312
312
+
addRecentSearch(item);
313
313
+
setRecentSearches(getRecentSearches());
314
314
+
setShowSearch(false);
315
315
+
navigate(item.path);
264
316
} else {
265
265
-
const adjustedIndex = index - prefixSuggestions.length;
317
317
+
const adjustedIndex = index - recent.length;
266
318
if (results && results[adjustedIndex]) {
319
319
+
const actor = results[adjustedIndex];
320
320
+
const path = `/at://${actor.did}`;
321
321
+
saveRecentSearch(path, actor.handle, "handle");
267
322
setShowSearch(false);
268
268
-
navigate(`/at://${results[adjustedIndex].did}`);
323
323
+
navigate(path);
269
324
}
270
325
}
271
271
-
} else if (results?.length && prefixSuggestions.length === 0) {
326
326
+
} else if (results?.length && recent.length === 0) {
272
327
e.preventDefault();
328
328
+
const actor = results[0];
329
329
+
const path = `/at://${actor.did}`;
330
330
+
saveRecentSearch(path, actor.handle, "handle");
273
331
setShowSearch(false);
274
274
-
navigate(`/at://${results[0].did}`);
332
332
+
navigate(path);
275
333
}
276
334
}
277
335
}}
278
336
/>
279
279
-
<Show when={input()} fallback={ListUrlsTooltip()}>
280
280
-
<button
281
281
-
type="button"
282
282
-
class="dark:hover:bg-dark-100 flex items-center rounded-md p-1 hover:bg-neutral-100 active:bg-neutral-200 dark:active:bg-neutral-700"
283
283
-
onClick={() => setInput(undefined)}
284
284
-
>
285
285
-
<span class="iconify lucide--x"></span>
286
286
-
</button>
287
287
-
</Show>
288
337
</div>
289
338
290
290
-
<Show when={getPrefixSuggestions().length > 0 || (input() && search()?.length)}>
339
339
+
<Show when={getRecentSuggestions().length > 0 || search()?.length}>
291
340
<div
292
292
-
class="flex w-full flex-col border-t border-neutral-200 p-2 dark:border-neutral-700"
341
341
+
class={`flex w-full flex-col overflow-hidden border-t border-neutral-200 dark:border-neutral-700 ${input() ? "rounded-b-md" : ""}`}
293
342
onMouseDown={(e) => e.preventDefault()}
294
343
>
295
295
-
{/* Prefix suggestions */}
296
296
-
<For each={getPrefixSuggestions()}>
297
297
-
{(prefixItem, index) => (
344
344
+
{/* Recent searches */}
345
345
+
<Show when={getRecentSuggestions().length > 0}>
346
346
+
<div class="mt-2 mb-1 flex items-center justify-between px-3">
347
347
+
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400">
348
348
+
Recent
349
349
+
</span>
298
350
<button
299
351
type="button"
300
300
-
class={`flex items-center rounded-md p-2 ${
301
301
-
index() === selectedIndex() ?
302
302
-
"bg-neutral-200 dark:bg-neutral-700"
303
303
-
: "dark:hover:bg-dark-100 hover:bg-neutral-100 active:bg-neutral-200 dark:active:bg-neutral-700"
304
304
-
}`}
352
352
+
class="text-xs text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200"
305
353
onClick={() => {
306
306
-
setInput(prefixItem.prefix);
307
307
-
setSelectedIndex(-1);
308
308
-
searchInput.focus();
354
354
+
localStorage.removeItem(RECENT_SEARCHES_KEY);
355
355
+
setRecentSearches([]);
309
356
}}
310
357
>
311
311
-
<span class={`text-sm font-semibold`}>{prefixItem.prefix}</span>
312
312
-
<span class="text-sm text-neutral-600 dark:text-neutral-400">
313
313
-
{prefixItem.description}
314
314
-
</span>
358
358
+
Clear all
315
359
</button>
316
316
-
)}
317
317
-
</For>
360
360
+
</div>
361
361
+
<For each={getRecentSuggestions()}>
362
362
+
{(recent, index) => {
363
363
+
const icon =
364
364
+
recent.type === "handle" ? "lucide--at-sign"
365
365
+
: recent.type === "did" ? "lucide--user-round"
366
366
+
: recent.type === "at-uri" ? "lucide--link"
367
367
+
: recent.type === "lexicon" ? "lucide--book-open"
368
368
+
: recent.type === "pds" ? "lucide--hard-drive"
369
369
+
: "lucide--globe";
370
370
+
return (
371
371
+
<div
372
372
+
class={`group flex items-center ${
373
373
+
index() === selectedIndex() ?
374
374
+
"bg-neutral-200 dark:bg-neutral-700"
375
375
+
: "dark:hover:bg-dark-100 hover:bg-neutral-100"
376
376
+
}`}
377
377
+
>
378
378
+
<A
379
379
+
href={recent.path}
380
380
+
class="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-sm"
381
381
+
onClick={() => {
382
382
+
addRecentSearch(recent);
383
383
+
setRecentSearches(getRecentSearches());
384
384
+
setShowSearch(false);
385
385
+
}}
386
386
+
>
387
387
+
<span
388
388
+
class={`iconify ${icon} shrink-0 text-neutral-500 dark:text-neutral-400`}
389
389
+
></span>
390
390
+
<span class="truncate">{recent.label}</span>
391
391
+
</A>
392
392
+
<button
393
393
+
type="button"
394
394
+
class="mr-1 flex items-center rounded p-1 opacity-0 group-hover:opacity-100 hover:bg-neutral-300 dark:hover:bg-neutral-600"
395
395
+
onClick={() => {
396
396
+
removeRecentSearch(recent.path);
397
397
+
setRecentSearches(getRecentSearches());
398
398
+
}}
399
399
+
>
400
400
+
<span class="iconify lucide--x text-sm text-neutral-500 dark:text-neutral-400"></span>
401
401
+
</button>
402
402
+
</div>
403
403
+
);
404
404
+
}}
405
405
+
</For>
406
406
+
</Show>
318
407
319
408
{/* Typeahead results */}
320
409
<For each={search()}>
321
410
{(actor, index) => {
322
322
-
const adjustedIndex = getPrefixSuggestions().length + index();
411
411
+
const adjustedIndex = getRecentSuggestions().length + index();
412
412
+
const path = `/at://${actor.did}`;
323
413
return (
324
414
<A
325
325
-
class={`flex items-center gap-2 rounded-md p-2 ${
415
415
+
class={`flex items-center gap-2 px-3 py-1.5 ${
326
416
adjustedIndex === selectedIndex() ?
327
417
"bg-neutral-200 dark:bg-neutral-700"
328
418
: "dark:hover:bg-dark-100 hover:bg-neutral-100 active:bg-neutral-200 dark:active:bg-neutral-700"
329
419
}`}
330
330
-
href={`/at://${actor.did}`}
331
331
-
onClick={() => setShowSearch(false)}
420
420
+
href={path}
421
421
+
onClick={() => {
422
422
+
saveRecentSearch(path, actor.handle, "handle");
423
423
+
setShowSearch(false);
424
424
+
}}
332
425
>
333
426
<img
334
427
src={actor.avatar?.replace("img/avatar/", "img/avatar_thumbnail/")}
···
348
441
</For>
349
442
</div>
350
443
</Show>
444
444
+
<Show when={!input()}>
445
445
+
<div class="flex flex-col gap-1 border-t border-neutral-200 px-3 py-2 text-xs text-neutral-500 dark:border-neutral-700 dark:text-neutral-400">
446
446
+
<div class="flex flex-wrap gap-1.5">
447
447
+
<div>
448
448
+
@<span class="text-neutral-400 dark:text-neutral-500">pdsls.dev</span>
449
449
+
</div>
450
450
+
<div>did:</div>
451
451
+
<div>at://</div>
452
452
+
<div>
453
453
+
lex:
454
454
+
<span class="text-neutral-400 dark:text-neutral-500">app.bsky.feed.post</span>
455
455
+
</div>
456
456
+
<div>
457
457
+
pds:
458
458
+
<span class="text-neutral-400 dark:text-neutral-500">tngl.sh</span>
459
459
+
</div>
460
460
+
</div>
461
461
+
<span>
462
462
+
Paste links from{" "}
463
463
+
<For each={Object.values(appName).slice(0, 4)}>
464
464
+
{(name, i) => (
465
465
+
<>
466
466
+
{name}
467
467
+
{i() < 3 ? ", " : ""}
468
468
+
</>
469
469
+
)}
470
470
+
</For>
471
471
+
{Object.keys(appName).length > 4 && <span> & more</span>}
472
472
+
</span>
473
473
+
</div>
474
474
+
</Show>
351
475
</form>
352
476
</Modal>
353
477
);
354
478
};
355
355
-
356
356
-
const ListUrlsTooltip = () => {
357
357
-
const [openList, setOpenList] = createSignal(false);
358
358
-
359
359
-
let urls: Record<string, AppUrl[]> = {};
360
360
-
for (const [appUrl, appView] of Object.entries(appList)) {
361
361
-
if (!urls[appView]) urls[appView] = [appUrl as AppUrl];
362
362
-
else urls[appView].push(appUrl as AppUrl);
363
363
-
}
364
364
-
365
365
-
return (
366
366
-
<>
367
367
-
<Modal
368
368
-
open={openList()}
369
369
-
onClose={() => setOpenList(false)}
370
370
-
alignTop
371
371
-
contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-88 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md sm:w-104 dark:border-neutral-700"
372
372
-
>
373
373
-
<div class="mb-2 flex items-center gap-1 font-semibold">
374
374
-
<span class="iconify lucide--link"></span>
375
375
-
<span>Supported URLs</span>
376
376
-
</div>
377
377
-
<div class="mb-2 text-sm text-neutral-600 dark:text-neutral-400">
378
378
-
Links that will be parsed automatically, as long as all the data necessary is on the URL.
379
379
-
</div>
380
380
-
<div class="flex flex-col gap-2 text-sm">
381
381
-
<For each={Object.entries(appName)}>
382
382
-
{([appView, name]) => {
383
383
-
return (
384
384
-
<div>
385
385
-
<p class="font-semibold">{name}</p>
386
386
-
<div class="grid grid-cols-2 gap-x-4 text-neutral-600 dark:text-neutral-400">
387
387
-
<For each={urls[appView]}>
388
388
-
{(url) => (
389
389
-
<a
390
390
-
href={`${url.startsWith("localhost:") ? "http://" : "https://"}${url}`}
391
391
-
target="_blank"
392
392
-
class="hover:underline active:underline"
393
393
-
>
394
394
-
{url}
395
395
-
</a>
396
396
-
)}
397
397
-
</For>
398
398
-
</div>
399
399
-
</div>
400
400
-
);
401
401
-
}}
402
402
-
</For>
403
403
-
</div>
404
404
-
</Modal>
405
405
-
<button
406
406
-
type="button"
407
407
-
class="dark:hover:bg-dark-100 flex items-center rounded-md p-1 hover:bg-neutral-100 active:bg-neutral-200 dark:active:bg-neutral-700"
408
408
-
onClick={() => setOpenList(true)}
409
409
-
>
410
410
-
<span class="iconify lucide--help-circle text-neutral-600 dark:text-neutral-300"></span>
411
411
-
</button>
412
412
-
</>
413
413
-
);
414
414
-
};
415
415
-
416
416
-
export { Search, SearchButton };
+3
-9
src/utils/app-urls.ts
···
3
3
export enum App {
4
4
Bluesky,
5
5
Tangled,
6
6
-
Frontpage,
7
6
Pinksea,
8
8
-
Linkat,
7
7
+
Frontpage,
9
8
}
10
9
11
10
export const appName = {
12
11
[App.Bluesky]: "Bluesky",
13
12
[App.Tangled]: "Tangled",
14
14
-
[App.Frontpage]: "Frontpage",
15
13
[App.Pinksea]: "Pinksea",
16
16
-
[App.Linkat]: "Linkat",
14
14
+
[App.Frontpage]: "Frontpage",
17
15
};
18
16
19
17
export const appList: Record<AppUrl, App> = {
···
22
20
"bsky.app": App.Bluesky,
23
21
"catsky.social": App.Bluesky,
24
22
"deer.aylac.top": App.Bluesky,
25
25
-
"deer-social-ayla.pages.dev": App.Bluesky,
26
23
"deer.social": App.Bluesky,
27
24
"main.bsky.dev": App.Bluesky,
28
28
-
"social.daniela.lol": App.Bluesky,
29
25
"witchsky.app": App.Bluesky,
30
26
"tangled.org": App.Tangled,
31
27
"frontpage.fyi": App.Frontpage,
32
28
"pinksea.art": App.Pinksea,
33
33
-
"linkat.blue": App.Linkat,
34
29
};
35
30
36
31
export const appHandleLink: Record<App, (url: string[]) => string> = {
···
53
48
return `at://${user}/app.bsky.graph.follow/${rkey}`;
54
49
}
55
50
} else {
56
56
-
return `at://${user}`;
51
51
+
return `at://${user}/app.bsky.actor.profile/self`;
57
52
}
58
53
} else if (baseType === "starter-pack") {
59
54
return `at://${user}/app.bsky.graph.starterpack/${path[2]}`;
···
106
101
107
102
return `at://${path[0]}`;
108
103
},
109
109
-
[App.Linkat]: (path) => `at://${path[0]}/blue.linkat.board/self`,
110
104
};