diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 236580b..18b6d9d 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -5,6 +5,7 @@ import { BlogLayout } from "./components/BlogLayout"; import BlogHomePage from "./pages/BlogHomePage"; import CreatePostPage from "./pages/CreatePostPage"; import EditPostPage from "./pages/EditPostPage"; +import SearchResultsPage from "./pages/SearchResultsPage"; import { NIP19Page } from "./pages/NIP19Page"; import NotFound from "./pages/NotFound"; @@ -17,6 +18,7 @@ export function AppRouter() { } /> } /> } /> + } /> {/* NIP-19 route for all Nostr identifiers (npub, nprofile, naddr, note, nevent) */} } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000..36760a1 --- /dev/null +++ b/src/components/SearchBar.tsx @@ -0,0 +1,188 @@ +import { useState, useRef, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { Search, User, FileText, Loader2 } from 'lucide-react'; +import { useSearch } from '@/hooks/useSearch'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import type { NostrMetadata } from '@nostrify/nostrify'; + +export function SearchBar({ className }: { className?: string }) { + const [searchTerm, setSearchTerm] = useState(''); + const [showResults, setShowResults] = useState(false); + const searchRef = useRef(null); + const navigate = useNavigate(); + + const { data: results, isLoading } = useSearch(searchTerm, showResults); + + // Close results when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (searchRef.current && !searchRef.current.contains(event.target as Node)) { + setShowResults(false); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleResultClick = (type: 'profile' | 'article', pubkey: string, identifier?: string) => { + setShowResults(false); + setSearchTerm(''); + + if (type === 'profile') { + const npub = nip19.npubEncode(pubkey); + navigate(`/${npub}`); + } else if (type === 'article' && identifier) { + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey, + identifier, + }); + navigate(`/${naddr}`); + } + }; + + const handleInputChange = (value: string) => { + setSearchTerm(value); + setShowResults(value.length >= 2); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && searchTerm.trim().length >= 2) { + setShowResults(false); + navigate(`/search?q=${encodeURIComponent(searchTerm.trim())}`); + } + }; + + return ( +
+
+ + handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => searchTerm.length >= 2 && setShowResults(true)} + className="pl-9 pr-4" + /> + {isLoading && ( + + )} +
+ + {showResults && searchTerm.length >= 2 && ( + + + {isLoading ? ( +
+ Searching... +
+ ) : results && results.length > 0 ? ( +
+ {results.map((result, index) => { + if (result.type === 'profile') { + let metadata: NostrMetadata = {}; + try { + metadata = JSON.parse(result.event.content); + } catch { + // Invalid JSON + } + + const displayName = metadata.display_name || metadata.name || 'Anonymous'; + const nip05 = metadata.nip05; + const picture = metadata.picture; + const about = metadata.about; + + return ( + + ); + } else { + const title = result.event.tags.find(([name]) => name === 'title')?.[1] || 'Untitled'; + const summary = result.event.tags.find(([name]) => name === 'summary')?.[1]; + const identifier = result.event.tags.find(([name]) => name === 'd')?.[1] || ''; + const image = result.event.tags.find(([name]) => name === 'image')?.[1]; + + return ( + + ); + } + })} +
+ ) : ( +
+ No results found +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts new file mode 100644 index 0000000..d08b6ee --- /dev/null +++ b/src/hooks/useSearch.ts @@ -0,0 +1,100 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import type { NostrEvent } from '@nostrify/nostrify'; + +export interface SearchResult { + type: 'profile' | 'article'; + event: NostrEvent; +} + +export function useSearch(searchTerm: string, enabled = true) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['search', searchTerm], + queryFn: async (c) => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]); + const term = searchTerm.toLowerCase().trim(); + + if (!term || term.length < 2) { + return []; + } + + // Query both profiles (kind 0) and articles (kind 30023) in one request + const events = await nostr.query( + [ + { + kinds: [0, 30023], + limit: 100, + }, + ], + { signal } + ); + + const results: SearchResult[] = []; + + events.forEach((event) => { + if (event.kind === 0) { + // Profile search - check name, display_name, and nip05 + try { + const metadata = JSON.parse(event.content); + const name = metadata.name?.toLowerCase() || ''; + const displayName = metadata.display_name?.toLowerCase() || ''; + const nip05 = metadata.nip05?.toLowerCase() || ''; + const about = metadata.about?.toLowerCase() || ''; + + if ( + name.includes(term) || + displayName.includes(term) || + nip05.includes(term) || + about.includes(term) + ) { + results.push({ type: 'profile', event }); + } + } catch { + // Invalid JSON, skip this profile + } + } else if (event.kind === 30023) { + // Article search - check title, summary, and content + const title = event.tags.find(([name]) => name === 'title')?.[1]?.toLowerCase() || ''; + const summary = event.tags.find(([name]) => name === 'summary')?.[1]?.toLowerCase() || ''; + const content = event.content.toLowerCase(); + + if (title.includes(term) || summary.includes(term) || content.includes(term)) { + results.push({ type: 'article', event }); + } + } + }); + + // Sort results: prioritize exact matches in titles/names + results.sort((a, b) => { + if (a.type === 'profile' && b.type === 'profile') { + const aMetadata = JSON.parse(a.event.content); + const bMetadata = JSON.parse(b.event.content); + const aName = aMetadata.name?.toLowerCase() || ''; + const bName = bMetadata.name?.toLowerCase() || ''; + + const aExact = aName === term ? 1 : 0; + const bExact = bName === term ? 1 : 0; + + return bExact - aExact; + } + if (a.type === 'article' && b.type === 'article') { + const aTitle = a.event.tags.find(([name]) => name === 'title')?.[1]?.toLowerCase() || ''; + const bTitle = b.event.tags.find(([name]) => name === 'title')?.[1]?.toLowerCase() || ''; + + const aExact = aTitle.includes(term) ? 1 : 0; + const bExact = bTitle.includes(term) ? 1 : 0; + + return bExact - aExact; + } + // Articles before profiles + return a.type === 'article' ? -1 : 1; + }); + + // Limit to top 10 results + return results.slice(0, 10); + }, + enabled: enabled && searchTerm.length >= 2, + }); +} diff --git a/src/pages/BlogHomePage.tsx b/src/pages/BlogHomePage.tsx index f6188e6..ad69dae 100644 --- a/src/pages/BlogHomePage.tsx +++ b/src/pages/BlogHomePage.tsx @@ -6,6 +6,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Badge } from '@/components/ui/badge'; import { Calendar } from 'lucide-react'; import { RelaySelector } from '@/components/RelaySelector'; +import { SearchBar } from '@/components/SearchBar'; export default function BlogHomePage() { const { data: posts, isLoading } = useBlogPosts(); @@ -13,15 +14,14 @@ export default function BlogHomePage() { if (isLoading) { return (
-
- {/* Header skeleton */} -
- - +
+ {/* Search bar */} +
+
{/* Posts skeleton */} -
+
{[1, 2, 3].map((i) => ( @@ -40,7 +40,12 @@ export default function BlogHomePage() { if (!posts || posts.length === 0) { return (
-
+
+ {/* Search bar */} +
+ +
+ {/* Empty state */} @@ -59,7 +64,12 @@ export default function BlogHomePage() { return (
-
+
+ {/* Search bar */} +
+ +
+ {/* Posts grid */}
{posts.map((post) => { diff --git a/src/pages/SearchResultsPage.tsx b/src/pages/SearchResultsPage.tsx new file mode 100644 index 0000000..d402392 --- /dev/null +++ b/src/pages/SearchResultsPage.tsx @@ -0,0 +1,234 @@ +import { useSearchParams, Link, useNavigate } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { useSearch } from '@/hooks/useSearch'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { User, FileText, ArrowLeft, Calendar } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { SearchBar } from '@/components/SearchBar'; +import type { NostrMetadata } from '@nostrify/nostrify'; + +export default function SearchResultsPage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const searchTerm = searchParams.get('q') || ''; + + const { data: results, isLoading } = useSearch(searchTerm, true); + + const profiles = results?.filter(r => r.type === 'profile') || []; + const articles = results?.filter(r => r.type === 'article') || []; + + if (isLoading) { + return ( +
+
+ {/* Header */} +
+ +
+ +
+
+ + {/* Loading skeletons */} +
+ +
+ {[1, 2, 3].map((i) => ( + + + + + + + ))} +
+
+
+
+ ); + } + + return ( +
+
+ {/* Header with back button and search */} +
+ +
+ +
+
+ + {/* Search term heading */} + {searchTerm && ( +
+

+ Search Results for "{searchTerm}" +

+

+ Found {results?.length || 0} results +

+
+ )} + + {/* No results */} + {!isLoading && (!results || results.length === 0) && ( + + +

+ No results found for "{searchTerm}". Try a different search term. +

+
+
+ )} + + {/* Profiles section */} + {profiles.length > 0 && ( +
+

+ + Profiles ({profiles.length}) +

+
+ {profiles.map((result) => { + let metadata: NostrMetadata = {}; + try { + metadata = JSON.parse(result.event.content); + } catch { + // Invalid JSON + } + + const displayName = metadata.display_name || metadata.name || 'Anonymous'; + const nip05 = metadata.nip05; + const picture = metadata.picture; + const about = metadata.about; + const npub = nip19.npubEncode(result.event.pubkey); + + return ( + + + +
+ + + + + + +
+

{displayName}

+ {nip05 && ( +

{nip05}

+ )} +
+ {about && ( +

+ {about} +

+ )} + + + Profile + +
+
+
+ + ); + })} +
+
+ )} + + {/* Articles section */} + {articles.length > 0 && ( +
+

+ + Articles ({articles.length}) +

+
+ {articles.map((result) => { + const title = result.event.tags.find(([name]) => name === 'title')?.[1] || 'Untitled'; + const summary = result.event.tags.find(([name]) => name === 'summary')?.[1]; + const image = result.event.tags.find(([name]) => name === 'image')?.[1]; + const publishedAt = result.event.tags.find(([name]) => name === 'published_at')?.[1]; + const identifier = result.event.tags.find(([name]) => name === 'd')?.[1] || ''; + const hashtags = result.event.tags + .filter(([name]) => name === 't') + .map(([, value]) => value) + .slice(0, 3); + + const date = publishedAt + ? new Date(parseInt(publishedAt) * 1000) + : new Date(result.event.created_at * 1000); + + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: result.event.pubkey, + identifier, + }); + + return ( + + + {image && ( +
+ {title} +
+ )} + +

+ {title} +

+ {summary && ( +

+ {summary} +

+ )} +
+ +
+ + +
+ {hashtags.length > 0 && ( +
+ {hashtags.map((tag) => ( + + #{tag} + + ))} +
+ )} +
+
+ + ); + })} +
+
+ )} +
+
+ ); +}