add search bar and page // fixed some design issues

This commit is contained in:
2025-10-05 15:57:54 +02:00
parent 87092d5956
commit ff4d5b3116
5 changed files with 542 additions and 8 deletions

View File

@@ -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() {
<Route path="/" element={<BlogHomePage />} />
<Route path="/create" element={<CreatePostPage />} />
<Route path="/edit/:identifier" element={<EditPostPage />} />
<Route path="/search" element={<SearchResultsPage />} />
{/* NIP-19 route for all Nostr identifiers (npub, nprofile, naddr, note, nevent) */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}

View File

@@ -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<HTMLDivElement>(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<HTMLInputElement>) => {
if (e.key === 'Enter' && searchTerm.trim().length >= 2) {
setShowResults(false);
navigate(`/search?q=${encodeURIComponent(searchTerm.trim())}`);
}
};
return (
<div ref={searchRef} className={cn('relative w-full', className)}>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search users, articles..."
value={searchTerm}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => searchTerm.length >= 2 && setShowResults(true)}
className="pl-9 pr-4"
/>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
{showResults && searchTerm.length >= 2 && (
<Card className="absolute top-full mt-2 w-full z-50 max-h-96 overflow-auto shadow-lg">
<CardContent className="p-0">
{isLoading ? (
<div className="p-4 text-center text-sm text-muted-foreground">
Searching...
</div>
) : results && results.length > 0 ? (
<div className="divide-y">
{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 (
<button
key={`profile-${result.event.id}-${index}`}
onClick={() => handleResultClick('profile', result.event.pubkey)}
className="w-full p-3 hover:bg-muted/50 transition-colors flex items-start gap-3 text-left"
>
<Avatar className="h-10 w-10 flex-shrink-0">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback>
<User className="h-5 w-5" />
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
<User className="h-3 w-3 mr-1" />
Profile
</Badge>
</div>
<div className="font-medium mt-1 truncate">{displayName}</div>
{nip05 && (
<div className="text-xs text-muted-foreground truncate">{nip05}</div>
)}
{about && (
<div className="text-xs text-muted-foreground line-clamp-2 mt-1">
{about}
</div>
)}
</div>
</button>
);
} 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 (
<button
key={`article-${result.event.id}-${index}`}
onClick={() => handleResultClick('article', result.event.pubkey, identifier)}
className="w-full p-3 hover:bg-muted/50 transition-colors flex items-start gap-3 text-left"
>
{image ? (
<div className="h-10 w-16 flex-shrink-0 rounded overflow-hidden bg-muted">
<img
src={image}
alt={title}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="h-10 w-10 flex-shrink-0 rounded bg-muted flex items-center justify-center">
<FileText className="h-5 w-5 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
<FileText className="h-3 w-3 mr-1" />
Article
</Badge>
</div>
<div className="font-medium mt-1 line-clamp-1">{title}</div>
{summary && (
<div className="text-xs text-muted-foreground line-clamp-2 mt-1">
{summary}
</div>
)}
</div>
</button>
);
}
})}
</div>
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
No results found
</div>
)}
</CardContent>
</Card>
)}
</div>
);
}

100
src/hooks/useSearch.ts Normal file
View File

@@ -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,
});
}

View File

@@ -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 (
<div className="min-h-screen">
<div className="container max-w-6xl py-8 space-y-8">
{/* Header skeleton */}
<div className="space-y-4">
<Skeleton className="h-12 w-64" />
<Skeleton className="h-6 w-96" />
<div className="container max-w-6xl py-8 px-4 sm:px-6 lg:px-8 space-y-8">
{/* Search bar */}
<div className="max-w-2xl mx-auto">
<SearchBar />
</div>
{/* Posts skeleton */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<Skeleton className="h-48 w-full" />
@@ -40,7 +40,12 @@ export default function BlogHomePage() {
if (!posts || posts.length === 0) {
return (
<div className="min-h-screen">
<div className="container max-w-6xl py-8">
<div className="container max-w-6xl py-8 px-4 sm:px-6 lg:px-8 space-y-8">
{/* Search bar */}
<div className="max-w-2xl mx-auto">
<SearchBar />
</div>
{/* Empty state */}
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
@@ -59,7 +64,12 @@ export default function BlogHomePage() {
return (
<div className="min-h-screen">
<div className="container max-w-6xl py-8 px-4 sm:px-6 lg:px-8">
<div className="container max-w-6xl py-8 px-4 sm:px-6 lg:px-8 space-y-8">
{/* Search bar */}
<div className="max-w-2xl mx-auto">
<SearchBar />
</div>
{/* Posts grid */}
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => {

View File

@@ -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 (
<div className="min-h-screen">
<div className="container max-w-6xl py-8 space-y-8">
{/* Header */}
<div className="space-y-4">
<Button variant="ghost" onClick={() => navigate('/')}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Home
</Button>
<div className="max-w-2xl mx-auto">
<SearchBar />
</div>
</div>
{/* Loading skeletons */}
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full mt-2" />
</CardHeader>
</Card>
))}
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen">
<div className="container max-w-6xl py-8 px-4 sm:px-6 lg:px-8 space-y-8">
{/* Header with back button and search */}
<div className="space-y-4">
<Button variant="ghost" onClick={() => navigate('/')}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Home
</Button>
<div className="max-w-2xl mx-auto">
<SearchBar />
</div>
</div>
{/* Search term heading */}
{searchTerm && (
<div>
<h1 className="text-3xl font-bold">
Search Results for "{searchTerm}"
</h1>
<p className="text-muted-foreground mt-2">
Found {results?.length || 0} results
</p>
</div>
)}
{/* No results */}
{!isLoading && (!results || results.length === 0) && (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<p className="text-muted-foreground">
No results found for "{searchTerm}". Try a different search term.
</p>
</CardContent>
</Card>
)}
{/* Profiles section */}
{profiles.length > 0 && (
<section className="space-y-4">
<h2 className="text-2xl font-bold flex items-center gap-2">
<User className="h-6 w-6" />
Profiles ({profiles.length})
</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{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 (
<Link key={result.event.id} to={`/${npub}`}>
<Card className="hover:shadow-lg transition-shadow h-full">
<CardContent className="pt-6">
<div className="flex flex-col items-center text-center space-y-3">
<Avatar className="h-20 w-20">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback>
<User className="h-10 w-10" />
</AvatarFallback>
</Avatar>
<div className="space-y-1 w-full">
<h3 className="font-semibold text-lg truncate">{displayName}</h3>
{nip05 && (
<p className="text-xs text-muted-foreground truncate">{nip05}</p>
)}
</div>
{about && (
<p className="text-sm text-muted-foreground line-clamp-3 w-full">
{about}
</p>
)}
<Badge variant="outline" className="text-xs">
<User className="h-3 w-3 mr-1" />
Profile
</Badge>
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
</section>
)}
{/* Articles section */}
{articles.length > 0 && (
<section className="space-y-4">
<h2 className="text-2xl font-bold flex items-center gap-2">
<FileText className="h-6 w-6" />
Articles ({articles.length})
</h2>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{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 (
<Link key={result.event.id} to={`/${naddr}`}>
<Card className="overflow-hidden hover:shadow-lg transition-shadow h-full flex flex-col">
{image && (
<div className="aspect-video overflow-hidden bg-muted">
<img
src={image}
alt={title}
className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
/>
</div>
)}
<CardHeader className="flex-1">
<h3 className="text-xl font-bold line-clamp-2 mb-2">
{title}
</h3>
{summary && (
<p className="text-muted-foreground text-sm line-clamp-3">
{summary}
</p>
)}
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-3">
<Calendar className="h-3 w-3" />
<time dateTime={date.toISOString()}>
{date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
</div>
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1">
{hashtags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
#{tag}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
</Link>
);
})}
</div>
</section>
)}
</div>
</div>
);
}