diff --git a/src/components/MobileSearchSheet.tsx b/src/components/MobileSearchSheet.tsx index 912efe7b..cb9fddec 100644 --- a/src/components/MobileSearchSheet.tsx +++ b/src/components/MobileSearchSheet.tsx @@ -20,6 +20,8 @@ import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; import { useWikipediaSearch, type WikipediaSearchResult } from '@/hooks/useWikipediaSearch'; import { useArchiveSearch, type ArchiveSearchResult } from '@/hooks/useArchiveSearch'; import { WikipediaIcon } from '@/components/icons/WikipediaIcon'; +import { searchSidebarItems, type SidebarItemDef } from '@/lib/sidebarItems'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; import { cn } from '@/lib/utils'; interface MobileSearchSheetProps { @@ -30,6 +32,7 @@ interface MobileSearchSheetProps { export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { user } = useCurrentUser(); const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(-1); const inputRef = useRef(null); @@ -47,6 +50,9 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { // Country suggestion (local, synchronous) const countryMatch = useMemo(() => searchCountry(query), [query]); + // Nav item suggestions (local, synchronous) + const navItems = useMemo(() => searchSidebarItems(query, !!user), [query, user]); + // URL detection — show "Comment on" option when query is a full URL const queryIsUrl = useMemo(() => isFullUrl(query), [query]); const hasUrlComment = queryIsUrl; @@ -79,11 +85,14 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { const hasIdentifier = !!identifierMatch; const hasWikipedia = !!wikipediaResult; const hasArchive = !!archiveResult; + const navItemCount = navItems.length; - const totalItems = profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0); + const totalItems = navItemCount + profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0); - // Order: [identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?] + // Order: [...navItems, identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?] let nextMobileIdx = 0; + const navItemStartIndex = nextMobileIdx; + nextMobileIdx += navItemCount; const identifierIndex = hasIdentifier ? nextMobileIdx++ : -1; const urlCommentIndex = hasUrlComment ? nextMobileIdx++ : -1; const countryTopIndex = (hasCountry && countryAtTop) ? nextMobileIdx++ : -1; @@ -132,6 +141,11 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { navigate(path); }, [navigate, handleClose]); + const handleSelectNavItem = useCallback((item: SidebarItemDef) => { + handleClose(); + navigate(item.path); + }, [navigate, handleClose]); + const handleSelectWikipedia = useCallback((result: WikipediaSearchResult) => { handleClose(); navigate(`/i/${encodeURIComponent(result.url)}`); @@ -166,7 +180,9 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { if (e.key === 'Enter') { e.preventDefault(); if (selectedIndex >= 0 && selectedIndex < totalItems) { - if (hasIdentifier && selectedIndex === identifierIndex) { + if (navItemCount > 0 && selectedIndex >= navItemStartIndex && selectedIndex < navItemStartIndex + navItemCount) { + handleSelectNavItem(navItems[selectedIndex - navItemStartIndex]); + } else if (hasIdentifier && selectedIndex === identifierIndex) { // Identifier item navigation path is determined by the component // Trigger via its onClick handler const sheet = document.querySelector('[data-mobile-search-results]'); @@ -198,7 +214,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { } }; - const hasResults = query.trim().length > 0 && (hasIdentifier || hasUrlComment || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)); + const hasResults = query.trim().length > 0 && (navItemCount > 0 || hasIdentifier || hasUrlComment || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)); if (!open) return null; @@ -216,6 +232,14 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { {/* Results list — reversed so closest to input = most relevant */} {hasResults && (
+ {navItems.map((item, index) => ( + + ))} {hasIdentifier && ( void; +}) { + const Icon = item.icon; + + return ( + + ); +} + /** * Mobile autocomplete item for a detected Nostr identifier. */ diff --git a/src/components/ProfileSearchDropdown.tsx b/src/components/ProfileSearchDropdown.tsx index 60923d04..7e85bef4 100644 --- a/src/components/ProfileSearchDropdown.tsx +++ b/src/components/ProfileSearchDropdown.tsx @@ -22,6 +22,8 @@ import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; import { useWikipediaSearch, type WikipediaSearchResult } from '@/hooks/useWikipediaSearch'; import { useArchiveSearch, type ArchiveSearchResult } from '@/hooks/useArchiveSearch'; import { WikipediaIcon } from '@/components/icons/WikipediaIcon'; +import { searchSidebarItems, type SidebarItemDef } from '@/lib/sidebarItems'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; import { cn } from '@/lib/utils'; interface ProfileSearchDropdownProps { @@ -54,6 +56,7 @@ export function ProfileSearchDropdown({ const inputRef = useRef(null); const listRef = useRef(null); + const { user } = useCurrentUser(); const { data: rawProfiles, isFetching, followedPubkeys } = useSearchProfiles(query); // Wikipedia & Archive search (async, debounced by their hooks at >=2 chars) @@ -68,6 +71,9 @@ export function ProfileSearchDropdown({ const countryMatchRaw = useMemo(() => searchCountry(query), [query]); const countryMatch = hideCountry ? null : countryMatchRaw; + // Nav item suggestions (local, synchronous) + const navItems = useMemo(() => searchSidebarItems(query, !!user), [query, user]); + // URL detection — show "Comment on" option when query is a full URL const queryIsUrl = useMemo(() => isFullUrl(query), [query]); @@ -99,11 +105,11 @@ export function ProfileSearchDropdown({ // Show dropdown when we have results, or when text search is enabled and there's a query useEffect(() => { if (query.trim().length > 0) { - if (enableTextSearch || (profiles && profiles.length > 0) || countryMatch || wikipediaResult || archiveResult) { + if (enableTextSearch || (profiles && profiles.length > 0) || countryMatch || navItems.length > 0 || wikipediaResult || archiveResult) { setOpen(true); } } - }, [profiles, query, enableTextSearch, countryMatch, wikipediaResult, archiveResult]); + }, [profiles, query, enableTextSearch, countryMatch, navItems, wikipediaResult, archiveResult]); // Reset selected index when results change useEffect(() => { @@ -141,17 +147,20 @@ export function ProfileSearchDropdown({ navigate(`/search?q=${encodeURIComponent(query.trim())}`); }, [enableTextSearch, query, navigate]); - // Total selectable items: identifier? + URL comment? + country?(top) + profiles + country?(bottom) + wikipedia? + archive? + // Total selectable items: navItems + identifier? + URL comment? + country?(top) + profiles + country?(bottom) + wikipedia? + archive? const hasCountry = !!countryMatch; const hasUrlComment = queryIsUrl && enableTextSearch; const hasIdentifier = !!identifierMatch; const hasWikipedia = !!wikipediaResult; const hasArchive = !!archiveResult; - const totalItems = profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0); + const navItemCount = navItems.length; + const totalItems = navItemCount + profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0); // Map selectedIndex to what it refers to. - // Order: [identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?] + // Order: [...navItems, identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?] let nextIdx = 0; + const navItemStartIndex = nextIdx; + nextIdx += navItemCount; const identifierIndex = hasIdentifier ? nextIdx++ : -1; const urlCommentIndex = hasUrlComment ? nextIdx++ : -1; const countryTopIndex = (hasCountry && countryAtTop) ? nextIdx++ : -1; @@ -197,6 +206,13 @@ export function ProfileSearchDropdown({ navigate(path); }, [navigate]); + const handleSelectNavItem = useCallback((item: SidebarItemDef) => { + setOpen(false); + setQuery(''); + inputRef.current?.blur(); + navigate(item.path); + }, [navigate]); + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); @@ -208,7 +224,9 @@ export function ProfileSearchDropdown({ if (e.key === 'Enter') { e.preventDefault(); if (open && selectedIndex >= 0 && selectedIndex < totalItems) { - if (hasIdentifier && selectedIndex === identifierIndex) { + if (navItemCount > 0 && selectedIndex >= navItemStartIndex && selectedIndex < navItemStartIndex + navItemCount) { + handleSelectNavItem(navItems[selectedIndex - navItemStartIndex]); + } else if (hasIdentifier && selectedIndex === identifierIndex) { // Handled by the IdentifierItem component via its onClick // which calls handleSelectIdentifier — trigger via DOM click const items = listRef.current?.querySelectorAll('[data-search-item]'); @@ -303,13 +321,21 @@ export function ProfileSearchDropdown({
{/* Dropdown results — only when text search is not enabled */} - {!enableTextSearch && open && (hasIdentifier || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)) && ( + {!enableTextSearch && open && (navItemCount > 0 || hasIdentifier || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)) && (
+ {navItems.map((item, index) => ( + + ))} {hasIdentifier && ( + {/* Nav item results — sidebar pages matching query */} + {navItems.map((item, index) => ( + + ))} + {/* Identifier suggestion — NIP-05, NIP-19, hex */} {hasIdentifier && ( 0 && !isFetching && !hasIdentifier && !hasCountry && !hasWikipedia && !hasArchive && profiles && profiles.length === 0 && ( + {!enableTextSearch && open && query.trim().length > 0 && !isFetching && !hasIdentifier && !hasCountry && !hasWikipedia && !hasArchive && navItemCount === 0 && profiles && profiles.length === 0 && (
No profiles found @@ -459,6 +495,40 @@ export function ProfileSearchDropdown({ ); } +function NavItem({ + item, + isSelected, + onClick, +}: { + item: SidebarItemDef; + isSelected: boolean; + onClick: (item: SidebarItemDef) => void; +}) { + const Icon = item.icon; + + return ( + + ); +} + /** * Autocomplete item for a detected Nostr identifier (NIP-05, NIP-19, hex). * Resolves the identifier in the background and renders a profile or event preview. diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index 22332866..f19f39dd 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -227,6 +227,34 @@ export function itemPath( return SIDEBAR_ITEM_MAP.get(id)?.path ?? `/${id}`; } +/** + * Search sidebar items by label. Returns items whose label starts with or + * contains the query (case-insensitive). Prefix matches are sorted first. + * Auth-requiring items are excluded when the user is not logged in. + */ +export function searchSidebarItems( + query: string, + isLoggedIn: boolean, +): SidebarItemDef[] { + const q = query.trim().toLowerCase(); + if (q.length === 0) return []; + + const prefixMatches: SidebarItemDef[] = []; + const substringMatches: SidebarItemDef[] = []; + + for (const item of SIDEBAR_ITEMS) { + if (item.requiresAuth && !isLoggedIn) continue; + const label = item.label.toLowerCase(); + if (label.startsWith(q)) { + prefixMatches.push(item); + } else if (label.includes(q)) { + substringMatches.push(item); + } + } + + return [...prefixMatches, ...substringMatches]; +} + /** Check if a sidebar item is active given the current location. */ export function isItemActive( id: string,