mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-06-04 17:41:10 +02:00
Add NIP-19 routing and implement Profile, Note, and Event pages
This commit is contained in:
@@ -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>
|
||||
|
||||
62
src/hooks/useAuthorBlogPosts.ts
Normal file
62
src/hooks/useAuthorBlogPosts.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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
213
src/pages/EventPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
178
src/pages/NotePage.tsx
Normal 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
277
src/pages/ProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user