Add NIP-19 routing and implement Profile, Note, and Event pages

This commit is contained in:
2025-10-05 15:34:14 +02:00
parent 33a02c2625
commit 1a652c1dbe
7 changed files with 762 additions and 17 deletions

View File

@@ -3,9 +3,9 @@ import { ScrollToTop } from "./components/ScrollToTop";
import { BlogLayout } from "./components/BlogLayout";
import BlogHomePage from "./pages/BlogHomePage";
import BlogPostPage from "./pages/BlogPostPage";
import CreatePostPage from "./pages/CreatePostPage";
import EditPostPage from "./pages/EditPostPage";
import { NIP19Page } from "./pages/NIP19Page";
import NotFound from "./pages/NotFound";
export function AppRouter() {
@@ -17,8 +17,8 @@ export function AppRouter() {
<Route path="/" element={<BlogHomePage />} />
<Route path="/create" element={<CreatePostPage />} />
<Route path="/edit/:identifier" element={<EditPostPage />} />
{/* NIP-19 route for naddr1 blog posts */}
<Route path="/:naddr" element={<BlogPostPage />} />
{/* 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 */}
<Route path="*" element={<NotFound />} />
</Routes>

View File

@@ -0,0 +1,62 @@
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
interface BlogPost extends NostrEvent {
kind: 30023;
}
/**
* Validates that a Nostr event is a valid NIP-23 blog post
*/
function validateBlogPost(event: NostrEvent): event is BlogPost {
// Must be kind 30023
if (event.kind !== 30023) return false;
// Must have required tags
const d = event.tags.find(([name]) => name === 'd')?.[1];
const title = event.tags.find(([name]) => name === 'title')?.[1];
// d and title are required for addressable events
if (!d || !title) return false;
return true;
}
/**
* Hook to fetch blog posts from a specific author
*/
export function useAuthorBlogPosts(pubkey: string) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['author-blog-posts', pubkey],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
const events = await nostr.query(
[{
kinds: [30023],
authors: [pubkey],
limit: 50,
}],
{ signal }
);
// Filter and validate events
const validPosts = events.filter(validateBlogPost);
// Sort by published_at (newest first), fallback to created_at
return validPosts.sort((a, b) => {
const aPublished = a.tags.find(([name]) => name === 'published_at')?.[1];
const bPublished = b.tags.find(([name]) => name === 'published_at')?.[1];
const aTime = aPublished ? parseInt(aPublished) : a.created_at;
const bTime = bPublished ? parseInt(bPublished) : b.created_at;
return bTime - aTime;
});
},
enabled: !!pubkey,
});
}

View File

@@ -17,7 +17,7 @@ import { genUserName } from '@/lib/genUserName';
import NotFound from '@/pages/NotFound';
export default function BlogPostPage() {
const { naddr } = useParams<{ naddr: string }>();
const { nip19: naddr } = useParams<{ nip19: string }>();
const navigate = useNavigate();
const { user } = useCurrentUser();
@@ -25,6 +25,7 @@ export default function BlogPostPage() {
let pubkey = '';
let identifier = '';
let kind = 0;
let isValidNaddr = false;
try {
if (naddr?.startsWith('naddr1')) {
@@ -33,6 +34,7 @@ export default function BlogPostPage() {
pubkey = decoded.data.pubkey;
identifier = decoded.data.identifier;
kind = decoded.data.kind;
isValidNaddr = true;
}
}
} catch (error) {
@@ -51,7 +53,7 @@ export default function BlogPostPage() {
const isPostAuthor = user?.pubkey === post?.pubkey;
const hasReacted = reactions?.likes.some(like => like.pubkey === user?.pubkey);
if (!naddr || kind !== 30023) {
if (!isValidNaddr || !naddr || kind !== 30023) {
return <NotFound />;
}
@@ -120,7 +122,7 @@ export default function BlogPostPage() {
{/* Author info and metadata */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<Avatar className="h-10 w-10 sm:h-12 sm:w-12">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
@@ -138,7 +140,7 @@ export default function BlogPostPage() {
</time>
</div>
</div>
</div>
</Link>
{isPostAuthor && (
<Button

213
src/pages/EventPage.tsx Normal file
View File

@@ -0,0 +1,213 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useNostr } from '@nostrify/react';
import { useAuthor } from '@/hooks/useAuthor';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, Calendar, Hash } from 'lucide-react';
import { genUserName } from '@/lib/genUserName';
import type { NostrEvent } from '@nostrify/nostrify';
import NotFound from './NotFound';
interface EventPageProps {
eventId: string;
relayHints?: string[];
authorPubkey?: string;
kind?: number;
}
export function EventPage({ eventId, relayHints, authorPubkey, kind }: EventPageProps) {
const { nostr } = useNostr();
// Fetch the event
const { data: event, isLoading } = useQuery({
queryKey: ['event', eventId, authorPubkey, kind],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
// Build filter based on available data
const filter: {
ids: string[];
limit: number;
authors?: string[];
kinds?: number[];
} = { ids: [eventId], limit: 1 };
if (authorPubkey) filter.authors = [authorPubkey];
if (kind !== undefined) filter.kinds = [kind];
// Use relay hints if provided, otherwise use default pool
const relay = relayHints && relayHints.length > 0
? nostr.group(relayHints)
: nostr;
const events = await relay.query([filter], { signal });
return events[0] as NostrEvent | undefined;
},
});
const author = useAuthor(event?.pubkey || '');
const metadata = author.data?.metadata;
const displayName = metadata?.display_name || metadata?.name || genUserName(event?.pubkey || '');
const profileImage = metadata?.picture;
if (isLoading) {
return (
<div className="min-h-screen">
<div className="container max-w-3xl py-8 px-4">
<Skeleton className="h-8 w-24 mb-6" />
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="space-y-2 flex-1">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</CardContent>
</Card>
</div>
</div>
);
}
if (!event) {
return <NotFound />;
}
const date = new Date(event.created_at * 1000);
return (
<div className="min-h-screen">
<div className="container max-w-3xl py-8 px-4">
{/* Back button */}
<Button
variant="ghost"
size="sm"
onClick={() => window.history.back()}
className="mb-6"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
{/* Event card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between mb-4">
<Badge variant="outline" className="flex items-center gap-1">
<Hash className="h-3 w-3" />
Kind {event.kind}
</Badge>
<time
dateTime={date.toISOString()}
className="text-sm text-muted-foreground flex items-center gap-1"
>
<Calendar className="h-3 w-3" />
{date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</time>
</div>
<Link
to={`/${nip19.npubEncode(event.pubkey)}`}
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<Avatar className="h-12 w-12">
<AvatarImage src={profileImage} alt={displayName} />
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div>
<div className="font-semibold">{displayName}</div>
<div className="text-sm text-muted-foreground">
{event.pubkey.slice(0, 8)}...{event.pubkey.slice(-8)}
</div>
</div>
</Link>
</CardHeader>
<CardContent className="space-y-6">
{/* Event content */}
{event.content && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Content</h3>
<div className="bg-muted p-4 rounded-lg">
<pre className="whitespace-pre-wrap break-words text-sm font-mono">
{event.content}
</pre>
</div>
</div>
)}
{/* Event tags */}
{event.tags.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
<div className="space-y-1">
{event.tags.map((tag, index) => (
<div
key={index}
className="bg-muted p-2 rounded text-xs font-mono flex gap-2"
>
{tag.map((value, i) => (
<span key={i} className={i === 0 ? 'font-semibold' : ''}>
{value}
{i < tag.length - 1 && <span className="text-muted-foreground ml-2">,</span>}
</span>
))}
</div>
))}
</div>
</div>
)}
{/* Event metadata */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Metadata</h3>
<div className="bg-muted p-4 rounded-lg space-y-2 text-sm font-mono">
<div>
<span className="text-muted-foreground">ID:</span>{' '}
<span className="break-all">{event.id}</span>
</div>
<div>
<span className="text-muted-foreground">Pubkey:</span>{' '}
<span className="break-all">{event.pubkey}</span>
</div>
<div>
<span className="text-muted-foreground">Signature:</span>{' '}
<span className="break-all">{event.sig}</span>
</div>
</div>
</div>
{/* Raw JSON */}
<details>
<summary className="text-sm font-semibold text-muted-foreground cursor-pointer hover:text-foreground">
View Raw JSON
</summary>
<div className="mt-2 bg-muted p-4 rounded-lg">
<pre className="text-xs font-mono overflow-x-auto">
{JSON.stringify(event, null, 2)}
</pre>
</div>
</details>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,5 +1,9 @@
import { nip19 } from 'nostr-tools';
import { useParams } from 'react-router-dom';
import ProfilePage from './ProfilePage';
import BlogPostPage from './BlogPostPage';
import { NotePage } from './NotePage';
import { EventPage } from './EventPage';
import NotFound from './NotFound';
export function NIP19Page() {
@@ -16,25 +20,34 @@ export function NIP19Page() {
return <NotFound />;
}
const { type } = decoded;
const { type, data } = decoded;
switch (type) {
case 'npub':
case 'nprofile':
// AI agent should implement profile view here
return <div>Profile placeholder</div>;
// Render profile page - ProfilePage will handle validation
return <ProfilePage />;
case 'note':
// AI agent should implement note view here
return <div>Note placeholder</div>;
// Render kind:1 text note
return <NotePage eventId={data as string} />;
case 'nevent':
// AI agent should implement event view here
return <div>Event placeholder</div>;
case 'nevent': {
// Render any event with optional relay hints and author
const eventData = data as { id: string; relays?: string[]; author?: string; kind?: number };
return (
<EventPage
eventId={eventData.id}
relayHints={eventData.relays}
authorPubkey={eventData.author}
kind={eventData.kind}
/>
);
}
case 'naddr':
// AI agent should implement addressable event view here
return <div>Addressable event placeholder</div>;
// Render addressable event (blog post) - BlogPostPage will handle validation
return <BlogPostPage />;
default:
return <NotFound />;

178
src/pages/NotePage.tsx Normal file
View File

@@ -0,0 +1,178 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useNostr } from '@nostrify/react';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useReactions, useReact } from '@/hooks/useReactions';
import { NoteContent } from '@/components/NoteContent';
import { ZapButton } from '@/components/ZapButton';
import { CommentsSection } from '@/components/comments/CommentsSection';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Heart, MessageCircle, ArrowLeft } from 'lucide-react';
import { genUserName } from '@/lib/genUserName';
import type { NostrEvent } from '@nostrify/nostrify';
import NotFound from './NotFound';
interface NotePageProps {
eventId: string;
}
export function NotePage({ eventId }: NotePageProps) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
// Fetch the note event
const { data: note, isLoading } = useQuery({
queryKey: ['note', eventId],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
const events = await nostr.query(
[{ ids: [eventId], kinds: [1], limit: 1 }],
{ signal }
);
return events[0] as NostrEvent | undefined;
},
});
const author = useAuthor(note?.pubkey || '');
const { data: reactions } = useReactions(eventId, note?.pubkey || '');
const { mutate: react } = useReact();
const metadata = author.data?.metadata;
const displayName = metadata?.display_name || metadata?.name || genUserName(note?.pubkey || '');
const profileImage = metadata?.picture;
const hasReacted = reactions?.likes.some(like => like.pubkey === user?.pubkey);
const handleReact = () => {
if (!user || !note) return;
if (hasReacted) return;
react({ eventId: note.id, eventAuthor: note.pubkey });
};
if (isLoading) {
return (
<div className="min-h-screen">
<div className="container max-w-2xl py-8 px-4">
<Skeleton className="h-8 w-24 mb-6" />
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="space-y-2 flex-1">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</CardContent>
</Card>
</div>
</div>
);
}
if (!note) {
return <NotFound />;
}
const date = new Date(note.created_at * 1000);
return (
<div className="min-h-screen">
<div className="container max-w-2xl py-8 px-4">
{/* Back button */}
<Button
variant="ghost"
size="sm"
onClick={() => window.history.back()}
className="mb-6"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
{/* Note card */}
<Card>
<CardHeader>
<Link
to={`/${nip19.npubEncode(note.pubkey)}`}
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<Avatar className="h-12 w-12">
<AvatarImage src={profileImage} alt={displayName} />
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div>
<div className="font-semibold">{displayName}</div>
<time
dateTime={date.toISOString()}
className="text-sm text-muted-foreground"
>
{date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</time>
</div>
</Link>
</CardHeader>
<CardContent className="space-y-4">
{/* Note content */}
<div className="whitespace-pre-wrap break-words">
<NoteContent event={note} className="text-base" />
</div>
<Separator />
{/* Interaction buttons */}
<div className="flex items-center gap-6">
<Button
variant="ghost"
size="sm"
onClick={handleReact}
disabled={!user || hasReacted}
className={hasReacted ? 'text-red-500' : ''}
>
<Heart
className={`h-4 w-4 mr-2 ${hasReacted ? 'fill-current' : ''}`}
/>
{reactions?.likes.length || 0}
</Button>
<Button variant="ghost" size="sm" disabled>
<MessageCircle className="h-4 w-4 mr-2" />
Comments
</Button>
{note && (
<ZapButton
target={note}
showCount={true}
/>
)}
</div>
</CardContent>
</Card>
{/* Comments section */}
<div className="mt-8">
<CommentsSection root={note} />
</div>
</div>
</div>
);
}

277
src/pages/ProfilePage.tsx Normal file
View File

@@ -0,0 +1,277 @@
import { useParams, Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthorBlogPosts } from '@/hooks/useAuthorBlogPosts';
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 { Button } from '@/components/ui/button';
import { Calendar, MapPin, Link2, Mail } from 'lucide-react';
import { genUserName } from '@/lib/genUserName';
import { RelaySelector } from '@/components/RelaySelector';
import NotFound from '@/pages/NotFound';
export default function ProfilePage() {
const { nip19: npub } = useParams<{ nip19: string }>();
// Decode npub/nprofile to get pubkey
let pubkey = '';
let isValidProfile = false;
try {
if (npub?.startsWith('npub1')) {
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
pubkey = decoded.data;
isValidProfile = true;
}
} else if (npub?.startsWith('nprofile1')) {
const decoded = nip19.decode(npub);
if (decoded.type === 'nprofile') {
pubkey = decoded.data.pubkey;
isValidProfile = true;
}
}
} catch (error) {
console.error('Failed to decode npub:', error);
}
const author = useAuthor(pubkey);
const { data: posts, isLoading: postsLoading } = useAuthorBlogPosts(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey);
const userName = metadata?.name || genUserName(pubkey);
const profileImage = metadata?.picture;
const banner = metadata?.banner;
const about = metadata?.about;
const website = metadata?.website;
const nip05 = metadata?.nip05;
// If not a valid profile identifier, show 404
if (!isValidProfile || !pubkey) {
return <NotFound />;
}
// Loading state
if (author.isLoading) {
return (
<div className="min-h-screen">
<div className="container max-w-6xl py-0 md:py-8">
{/* Banner skeleton */}
<Skeleton className="h-48 md:h-64 w-full md:rounded-t-lg" />
{/* Profile info skeleton */}
<Card className="border-t-0 rounded-t-none md:rounded-t-none">
<CardContent className="pt-8">
<div className="flex flex-col md:flex-row gap-6">
<Skeleton className="h-24 w-24 md:h-32 md:w-32 rounded-full -mt-16 md:-mt-20 border-4 border-background" />
<div className="flex-1 space-y-4">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-5 w-32" />
</div>
<Skeleton className="h-4 w-full max-w-xl" />
<Skeleton className="h-4 w-3/4 max-w-lg" />
</div>
</div>
</CardContent>
</Card>
{/* Posts skeleton */}
<div className="mt-8 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}>
<Skeleton className="h-48 w-full" />
<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-0 md:py-8 px-0 md:px-4">
{/* Banner */}
<div className="relative h-48 md:h-64 w-full bg-gradient-to-br from-primary/20 to-primary/5 md:rounded-t-lg overflow-hidden">
{banner && (
<img
src={banner}
alt="Profile banner"
className="w-full h-full object-cover"
/>
)}
</div>
{/* Profile Info */}
<Card className="border-t-0 rounded-t-none md:rounded-t-none border-x-0 md:border-x">
<CardContent className="pt-8 px-4 md:px-6">
<div className="flex flex-col md:flex-row gap-6">
{/* Avatar */}
<Avatar className="h-24 w-24 md:h-32 md:w-32 -mt-16 md:-mt-20 border-4 border-background ring-2 ring-background">
<AvatarImage src={profileImage} alt={displayName} />
<AvatarFallback className="text-2xl md:text-4xl">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
{/* User Info */}
<div className="flex-1 space-y-4">
<div className="space-y-1">
<h1 className="text-2xl md:text-3xl font-bold">{displayName}</h1>
{metadata?.name && metadata.name !== displayName && (
<p className="text-muted-foreground">@{userName}</p>
)}
{nip05 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="h-4 w-4" />
<span>{nip05}</span>
</div>
)}
</div>
{about && (
<p className="text-sm md:text-base text-muted-foreground whitespace-pre-wrap max-w-2xl">
{about}
</p>
)}
{website && (
<div className="flex items-center gap-2 text-sm">
<Link2 className="h-4 w-4" />
<a
href={website}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{website.replace(/^https?:\/\//, '')}
</a>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Blog Posts Section */}
<div className="mt-8 px-4 md:px-0 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl md:text-2xl font-bold">Blog Posts</h2>
{posts && posts.length > 0 && (
<Badge variant="secondary" className="text-sm">
{posts.length} {posts.length === 1 ? 'post' : 'posts'}
</Badge>
)}
</div>
{postsLoading ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<Skeleton className="h-48 w-full" />
<CardHeader>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full mt-2" />
</CardHeader>
</Card>
))}
</div>
) : posts && posts.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => {
const title = post.tags.find(([name]) => name === 'title')?.[1] || 'Untitled';
const summary = post.tags.find(([name]) => name === 'summary')?.[1];
const image = post.tags.find(([name]) => name === 'image')?.[1];
const publishedAt = post.tags.find(([name]) => name === 'published_at')?.[1];
const tags = post.tags.filter(([name]) => name === 't').map(([, value]) => value);
const identifier = post.tags.find(([name]) => name === 'd')?.[1];
const naddr = nip19.naddrEncode({
kind: post.kind,
pubkey: post.pubkey,
identifier: identifier || '',
});
const date = publishedAt
? new Date(parseInt(publishedAt) * 1000)
: new Date(post.created_at * 1000);
return (
<Link key={post.id} to={`/${naddr}`}>
<Card className="h-full hover:shadow-lg transition-shadow cursor-pointer group overflow-hidden">
{image && (
<div className="relative h-48 overflow-hidden">
<img
src={image}
alt={title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
)}
<CardHeader className="space-y-3">
<h3 className="text-lg font-semibold line-clamp-2 group-hover:text-primary transition-colors">
{title}
</h3>
{summary && (
<p className="text-sm text-muted-foreground line-clamp-3">
{summary}
</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<time dateTime={date.toISOString()}>
{date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
{tags.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{tags.length - 3}
</Badge>
)}
</div>
)}
</CardHeader>
</Card>
</Link>
);
})}
</div>
) : (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<div className="max-w-sm mx-auto space-y-6">
<p className="text-muted-foreground">
No blog posts found from this author. Try another relay?
</p>
<RelaySelector className="w-full" />
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}