diff --git a/src/components/search.tsx b/src/components/search.tsx index 1bc689f..7260d16 100644 --- a/src/components/search.tsx +++ b/src/components/search.tsx @@ -16,6 +16,37 @@ import { appHandleLink, appList, appName, AppUrl } from "../utils/app-urls"; import { createDebouncedValue } from "../utils/hooks/debounced"; import { Modal } from "./modal"; +type RecentSearch = { + path: string; + label: string; + type: "handle" | "did" | "at-uri" | "lexicon" | "pds" | "url"; +}; + +const RECENT_SEARCHES_KEY = "recent-searches"; +const MAX_RECENT_SEARCHES = 5; + +const getRecentSearches = (): RecentSearch[] => { + try { + const stored = localStorage.getItem(RECENT_SEARCHES_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +}; + +const addRecentSearch = (search: RecentSearch) => { + const searches = getRecentSearches(); + const filtered = searches.filter((s) => s.path !== search.path); + const updated = [search, ...filtered].slice(0, MAX_RECENT_SEARCHES); + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); +}; + +const removeRecentSearch = (path: string) => { + const searches = getRecentSearches(); + const updated = searches.filter((s) => s.path !== path); + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); +}; + export const [showSearch, setShowSearch] = createSignal(false); const SEARCH_PREFIXES: { prefix: string; description: string }[] = [ @@ -37,7 +68,7 @@ const parsePrefix = (input: string): { prefix: string | null; query: string } => return { prefix: null, query: input }; }; -const SearchButton = () => { +export const SearchButton = () => { onMount(() => window.addEventListener("keydown", keyEvent)); onCleanup(() => window.removeEventListener("keydown", keyEvent)); @@ -79,12 +110,13 @@ const SearchButton = () => { ); }; -const Search = () => { +export const Search = () => { const navigate = useNavigate(); let searchInput!: HTMLInputElement; const rpc = new Client({ handler: simpleFetchHandler({ service: "https://public.api.bsky.app" }), }); + const [recentSearches, setRecentSearches] = createSignal(getRecentSearches()); onMount(() => { const handlePaste = (e: ClipboardEvent) => { @@ -136,14 +168,15 @@ const Search = () => { fetchTypeahead, ); - const getPrefixSuggestions = () => { - const currentInput = input(); - if (!currentInput) return SEARCH_PREFIXES; - - const { prefix, query } = parsePrefix(currentInput); - if (prefix && query.length > 0) return []; + const getRecentSuggestions = () => { + const currentInput = input()?.toLowerCase(); + if (!currentInput) return recentSearches(); + return recentSearches().filter((r) => r.label.toLowerCase().includes(currentInput)); + }; - return SEARCH_PREFIXES.filter((p) => p.prefix.startsWith(currentInput.toLowerCase())); + const saveRecentSearch = (path: string, label: string, type: RecentSearch["type"]) => { + addRecentSearch({ path, label, type }); + setRecentSearches(getRecentSearches()); }; const processInput = async (input: string) => { @@ -161,37 +194,55 @@ const Search = () => { const { prefix, query } = parsePrefix(input); if (prefix === "@") { - navigate(`/at://${query}`); + const path = `/at://${query}`; + saveRecentSearch(path, query, "handle"); + navigate(path); } else if (prefix === "did:") { - navigate(`/at://did:${query}`); + const path = `/at://did:${query}`; + saveRecentSearch(path, `did:${query}`, "did"); + navigate(path); } else if (prefix === "at:") { - navigate(`/${input}`); + const path = `/${input}`; + saveRecentSearch(path, input, "at-uri"); + navigate(path); } else if (prefix === "lex:") { if (query.split(".").length >= 3) { const nsid = query as Nsid; const res = await resolveLexiconAuthority(nsid); - navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`); + const path = `/at://${res}/com.atproto.lexicon.schema/${nsid}`; + saveRecentSearch(path, query, "lexicon"); + navigate(path); } else { const did = await resolveLexiconAuthorityDirect(query); - navigate(`/at://${did}/com.atproto.lexicon.schema`); + const path = `/at://${did}/com.atproto.lexicon.schema`; + saveRecentSearch(path, query, "lexicon"); + navigate(path); } } else if (prefix === "pds:") { - navigate(`/${query}`); + const path = `/${query}`; + saveRecentSearch(path, query, "pds"); + navigate(path); } else if (input.startsWith("https://") || input.startsWith("http://")) { const hostLength = input.indexOf("/", 8); const host = input.slice(0, hostLength).replace("https://", "").replace("http://", ""); if (!(host in appList)) { - navigate(`/${input.replace("https://", "").replace("http://", "").replace("/", "")}`); + const path = `/${input.replace("https://", "").replace("http://", "").replace("/", "")}`; + saveRecentSearch(path, input, "url"); + navigate(path); } else { const app = appList[host as AppUrl]; - const path = input.slice(hostLength + 1).split("/"); - - const uri = appHandleLink[app](path); - navigate(`/${uri}`); + const pathParts = input.slice(hostLength + 1).split("/"); + const uri = appHandleLink[app](pathParts); + const path = `/${uri}`; + saveRecentSearch(path, input, "url"); + navigate(path); } } else { - navigate(`/at://${input.replace("at://", "")}`); + const path = `/at://${input.replace("at://", "")}`; + const type = input.split("/").length > 1 ? "at-uri" : "handle"; + saveRecentSearch(path, input, type); + navigate(path); } }; @@ -200,7 +251,7 @@ const Search = () => { open={showSearch()} onClose={() => setShowSearch(false)} alignTop - 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" + 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" >
{ }} >
0 || search()?.length ? "rounded-t-lg" : "rounded-lg" + class={`flex items-center gap-2 px-3 ${ + getRecentSuggestions().length > 0 || search()?.length ? "rounded-t-lg" : "rounded-lg" }`} >
- 0 || (input() && search()?.length)}> + 0 || search()?.length}>
e.preventDefault()} > - {/* Prefix suggestions */} - - {(prefixItem, index) => ( + {/* Recent searches */} + 0}> +
+ + Recent + - )} - +
+ + {(recent, index) => { + const icon = + recent.type === "handle" ? "lucide--at-sign" + : recent.type === "did" ? "lucide--user-round" + : recent.type === "at-uri" ? "lucide--link" + : recent.type === "lexicon" ? "lucide--book-open" + : recent.type === "pds" ? "lucide--hard-drive" + : "lucide--globe"; + return ( +
+ { + addRecentSearch(recent); + setRecentSearches(getRecentSearches()); + setShowSearch(false); + }} + > + + {recent.label} + + +
+ ); + }} +
+
{/* Typeahead results */} {(actor, index) => { - const adjustedIndex = getPrefixSuggestions().length + index(); + const adjustedIndex = getRecentSuggestions().length + index(); + const path = `/at://${actor.did}`; return ( setShowSearch(false)} + href={path} + onClick={() => { + saveRecentSearch(path, actor.handle, "handle"); + setShowSearch(false); + }} > {
+ +
+
+
+ @pdsls.dev +
+
did:
+
at://
+
+ lex: + app.bsky.feed.post +
+
+ pds: + tngl.sh +
+
+ + Paste links from{" "} + + {(name, i) => ( + <> + {name} + {i() < 3 ? ", " : ""} + + )} + + {Object.keys(appName).length > 4 && & more} + +
+
); }; - -const ListUrlsTooltip = () => { - const [openList, setOpenList] = createSignal(false); - - let urls: Record = {}; - for (const [appUrl, appView] of Object.entries(appList)) { - if (!urls[appView]) urls[appView] = [appUrl as AppUrl]; - else urls[appView].push(appUrl as AppUrl); - } - - return ( - <> - setOpenList(false)} - alignTop - 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" - > -
- - Supported URLs -
-
- Links that will be parsed automatically, as long as all the data necessary is on the URL. -
-
- - - - ); -}; - -export { Search, SearchButton }; diff --git a/src/utils/app-urls.ts b/src/utils/app-urls.ts index 9898dcc..fc677f9 100644 --- a/src/utils/app-urls.ts +++ b/src/utils/app-urls.ts @@ -3,17 +3,15 @@ export type AppUrl = `${string}.${string}` | `localhost:${number}`; export enum App { Bluesky, Tangled, - Frontpage, Pinksea, - Linkat, + Frontpage, } export const appName = { [App.Bluesky]: "Bluesky", [App.Tangled]: "Tangled", - [App.Frontpage]: "Frontpage", [App.Pinksea]: "Pinksea", - [App.Linkat]: "Linkat", + [App.Frontpage]: "Frontpage", }; export const appList: Record = { @@ -22,15 +20,12 @@ export const appList: Record = { "bsky.app": App.Bluesky, "catsky.social": App.Bluesky, "deer.aylac.top": App.Bluesky, - "deer-social-ayla.pages.dev": App.Bluesky, "deer.social": App.Bluesky, "main.bsky.dev": App.Bluesky, - "social.daniela.lol": App.Bluesky, "witchsky.app": App.Bluesky, "tangled.org": App.Tangled, "frontpage.fyi": App.Frontpage, "pinksea.art": App.Pinksea, - "linkat.blue": App.Linkat, }; export const appHandleLink: Record string> = { @@ -53,7 +48,7 @@ export const appHandleLink: Record string> = { return `at://${user}/app.bsky.graph.follow/${rkey}`; } } else { - return `at://${user}`; + return `at://${user}/app.bsky.actor.profile/self`; } } else if (baseType === "starter-pack") { return `at://${user}/app.bsky.graph.starterpack/${path[2]}`; @@ -106,5 +101,4 @@ export const appHandleLink: Record string> = { return `at://${path[0]}`; }, - [App.Linkat]: (path) => `at://${path[0]}/blue.linkat.board/self`, };