mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-06-04 09:31:14 +02:00
add search bar and page // fixed some design issues
This commit is contained in:
@@ -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 */}
|
||||
|
||||
188
src/components/SearchBar.tsx
Normal file
188
src/components/SearchBar.tsx
Normal 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
100
src/hooks/useSearch.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
234
src/pages/SearchResultsPage.tsx
Normal file
234
src/pages/SearchResultsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user