search dropdown
This commit is contained in:
@ -1,7 +1,6 @@
|
|||||||
.search {
|
.search {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 9px 16px;
|
|
||||||
background: var(--gray-superdark);
|
background: var(--gray-superdark);
|
||||||
border-radius: 1000px;
|
border-radius: 1000px;
|
||||||
}
|
}
|
||||||
@ -16,7 +15,11 @@
|
|||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 21px;
|
line-height: 21px;
|
||||||
padding: 0 !important;
|
padding: 9px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search > svg {
|
||||||
|
margin: 9px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@ -28,4 +31,4 @@
|
|||||||
.search input {
|
.search input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,19 +1,70 @@
|
|||||||
import "./SearchBox.css";
|
import "./SearchBox.css";
|
||||||
import Spinner from "../Icons/Spinner";
|
import Spinner from "../Icons/Spinner";
|
||||||
import Icon from "../Icons/Icon";
|
import Icon from "../Icons/Icon";
|
||||||
import { useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { fetchNip05Pubkey } from "../Nip05/Verifier";
|
import { fetchNip05Pubkey } from "../Nip05/Verifier";
|
||||||
import { useState } from "react";
|
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||||
import { NostrLink, NostrPrefix, tryParseNostrLink } from "@snort/system";
|
import { NostrLink, tryParseNostrLink } from "@snort/system";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { unixNow } from "@snort/shared";
|
||||||
|
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "../Feed/TimelineFeed";
|
||||||
|
import Note from "./Event/Note";
|
||||||
|
|
||||||
|
const MAX_RESULTS = 3;
|
||||||
|
|
||||||
export default function SearchBox() {
|
export default function SearchBox() {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
async function searchThing() {
|
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||||
|
const resultListRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const options: TimelineFeedOptions = {
|
||||||
|
method: "LIMIT_UNTIL",
|
||||||
|
window: undefined,
|
||||||
|
now: unixNow(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const subject: TimelineSubject = {
|
||||||
|
type: "profile_keyword",
|
||||||
|
discriminator: search,
|
||||||
|
items: [search],
|
||||||
|
relay: undefined,
|
||||||
|
streams: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { main } = useTimelineFeed(subject, options);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const inputElement = resultListRef.current?.previousSibling as HTMLElement;
|
||||||
|
|
||||||
|
inputElement?.addEventListener("focus", () => {
|
||||||
|
document.addEventListener("keydown", handleGlobalKeyDown);
|
||||||
|
});
|
||||||
|
inputElement?.addEventListener("blur", () => {
|
||||||
|
document.removeEventListener("keydown", handleGlobalKeyDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleGlobalKeyDown);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Close the search on navigation
|
||||||
|
setSearch("");
|
||||||
|
setActiveIndex(-1);
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
const executeSearch = async () => {
|
||||||
try {
|
try {
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
const link = tryParseNostrLink(search);
|
const link = tryParseNostrLink(search);
|
||||||
@ -25,36 +76,90 @@ export default function SearchBox() {
|
|||||||
const [handle, domain] = search.split("@");
|
const [handle, domain] = search.split("@");
|
||||||
const pk = await fetchNip05Pubkey(handle, domain);
|
const pk = await fetchNip05Pubkey(handle, domain);
|
||||||
if (pk) {
|
if (pk) {
|
||||||
navigate(`/${new NostrLink(NostrPrefix.PublicKey, pk).encode()}`);
|
navigate(`/${new NostrLink(CONFIG.profileLinkPrefix, pk).encode()}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
navigate(`/search/${encodeURIComponent(search)}`);
|
navigate(`/search/${encodeURIComponent(search)}`);
|
||||||
} finally {
|
} finally {
|
||||||
setSearch("");
|
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!e.target.value.match(/nsec1[a-zA-Z0-9]{20,65}/gi)) {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case "Enter":
|
||||||
|
if (activeIndex === 0) {
|
||||||
|
navigate(`/search/${encodeURIComponent(search)}`);
|
||||||
|
} else if (activeIndex > 0 && main) {
|
||||||
|
const selectedResult = main[activeIndex - 1];
|
||||||
|
navigate(`/${new NostrLink(CONFIG.profileLinkPrefix, selectedResult.pubkey).encode()}`);
|
||||||
|
} else {
|
||||||
|
executeSearch();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex(prev => Math.min(prev + 1, Math.min(MAX_RESULTS, main ? main.length : 0)));
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex(prev => Math.max(prev - 1, 0));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="search">
|
<div className="search relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={formatMessage({ defaultMessage: "Search" })}
|
placeholder={formatMessage({ defaultMessage: "Search" })}
|
||||||
className="w-max"
|
className="w-max"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={handleChange}
|
||||||
onKeyDown={async e => {
|
onKeyDown={handleKeyDown}
|
||||||
if (e.key === "Enter") {
|
|
||||||
await searchThing();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{searching ? (
|
{searching ? (
|
||||||
<Spinner width={24} height={24} />
|
<Spinner width={24} height={24} />
|
||||||
) : (
|
) : (
|
||||||
<Icon name="search" size={24} onClick={() => navigate("/search")} />
|
<Icon name="search" size={24} onClick={() => navigate("/search")} />
|
||||||
)}
|
)}
|
||||||
|
{search && !searching && (
|
||||||
|
<div
|
||||||
|
className="absolute top-full mt-2 w-full border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-black shadow-lg rounded-lg z-10 overflow-hidden"
|
||||||
|
ref={resultListRef}>
|
||||||
|
<div
|
||||||
|
className={`p-2 cursor-pointer ${
|
||||||
|
activeIndex === 0
|
||||||
|
? "bg-neutral-300 dark:bg-neutral-800 hover:bg-neutral-400 dark:hover:bg-neutral-600"
|
||||||
|
: "hover:bg-neutral-200 dark:hover:bg-neutral-800"
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => setActiveIndex(0)}
|
||||||
|
onClick={() => navigate(`/search/${encodeURIComponent(search)}`, { state: { forceRefresh: true } })}>
|
||||||
|
<FormattedMessage defaultMessage="Search notes" />: <b>{search}</b>
|
||||||
|
</div>
|
||||||
|
{main?.slice(0, MAX_RESULTS).map((result, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`p-2 cursor-pointer ${
|
||||||
|
activeIndex === idx + 1
|
||||||
|
? "bg-neutral-300 dark:bg-neutral-800 hover:bg-neutral-400 dark:hover:bg-neutral-600"
|
||||||
|
: "hover:bg-neutral-200 dark:hover:bg-neutral-800"
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => setActiveIndex(idx + 1)}>
|
||||||
|
<Note data={result} depth={0} related={[]} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -22,7 +22,8 @@ export default function NostrLinkHandler() {
|
|||||||
if (nav.type === NostrPrefix.Event || nav.type === NostrPrefix.Note || nav.type === NostrPrefix.Address) {
|
if (nav.type === NostrPrefix.Event || nav.type === NostrPrefix.Note || nav.type === NostrPrefix.Address) {
|
||||||
setRenderComponent(<ThreadRoute id={nav.encode()} />); // Directly render ThreadRoute
|
setRenderComponent(<ThreadRoute id={nav.encode()} />); // Directly render ThreadRoute
|
||||||
} else if (nav.type === NostrPrefix.PublicKey || nav.type === NostrPrefix.Profile) {
|
} else if (nav.type === NostrPrefix.PublicKey || nav.type === NostrPrefix.Profile) {
|
||||||
setRenderComponent(<ProfilePage id={nav.encode()} state={state} />); // Directly render ProfilePage
|
const id = nav.encode();
|
||||||
|
setRenderComponent(<ProfilePage key={id} id={id} state={state} />); // Directly render ProfilePage
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (state) {
|
if (state) {
|
||||||
|
@ -401,6 +401,9 @@
|
|||||||
"E8a4yq": {
|
"E8a4yq": {
|
||||||
"defaultMessage": "Follow some popular accounts"
|
"defaultMessage": "Follow some popular accounts"
|
||||||
},
|
},
|
||||||
|
"EJbFi7": {
|
||||||
|
"defaultMessage": "Search notes"
|
||||||
|
},
|
||||||
"ELbg9p": {
|
"ELbg9p": {
|
||||||
"defaultMessage": "Data Providers"
|
"defaultMessage": "Data Providers"
|
||||||
},
|
},
|
||||||
|
@ -131,6 +131,7 @@
|
|||||||
"Dn82AL": "Live",
|
"Dn82AL": "Live",
|
||||||
"DtYelJ": "Transfer",
|
"DtYelJ": "Transfer",
|
||||||
"E8a4yq": "Follow some popular accounts",
|
"E8a4yq": "Follow some popular accounts",
|
||||||
|
"EJbFi7": "Search notes",
|
||||||
"ELbg9p": "Data Providers",
|
"ELbg9p": "Data Providers",
|
||||||
"EPYwm7": "Your private key is your password. If you lose this key, you will lose access to your account! Copy it and keep it in a safe place. There is no way to reset your private key.",
|
"EPYwm7": "Your private key is your password. If you lose this key, you will lose access to your account! Copy it and keep it in a safe place. There is no way to reset your private key.",
|
||||||
"EQKRE4": "Show badges on profile pages",
|
"EQKRE4": "Show badges on profile pages",
|
||||||
|
Reference in New Issue
Block a user