Add Article by d-tag route and implement useBlogPostByDTag hook

- Introduced a new route for articles identified by d-tag in AppRouter.
- Created useBlogPostByDTag hook to fetch blog posts by d-tag.
- Added ArticleByDTagPage component to display articles based on d-tag.
- Enhanced BlogPostPage to include JSON viewing functionality.
This commit is contained in:
2025-10-07 21:34:55 +02:00
parent 6f3e9239cc
commit b839de7a3b
4 changed files with 462 additions and 1 deletions

View File

@@ -9,6 +9,7 @@ import SearchResultsPage from "./pages/SearchResultsPage";
import { BookmarksPage } from "./pages/BookmarksPage";
import FollowingPage from "./pages/FollowingPage";
import Nip05ProfilePage from "./pages/Nip05ProfilePage";
import ArticleByDTagPage from "./pages/ArticleByDTagPage";
import { NIP19Page } from "./pages/NIP19Page";
import NotFound from "./pages/NotFound";
@@ -26,6 +27,8 @@ export function AppRouter() {
<Route path="/following" element={<FollowingPage />} />
{/* NIP-05 profile route (e.g., /p/alice@example.com) */}
<Route path="/p/:nip05" element={<Nip05ProfilePage />} />
{/* Article by d-tag route (e.g., /article/my-article-slug) */}
<Route path="/article/:dtag" element={<ArticleByDTagPage />} />
{/* 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,58 @@
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 {
if (event.kind !== 30023) return false;
const d = event.tags.find(([name]) => name === 'd')?.[1];
const title = event.tags.find(([name]) => name === 'title')?.[1];
if (!d || !title) return false;
return true;
}
/**
* Hook to fetch blog posts by d-tag identifier only (without author pubkey)
* Returns the most recent article with this d-tag across all authors
*/
export function useBlogPostByDTag(identifier: string) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['blog-post-by-dtag', identifier],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
const events = await nostr.query(
[{
kinds: [30023],
'#d': [identifier],
limit: 10, // Get multiple in case there are duplicates
}],
{ signal }
);
if (events.length === 0) return null;
// Filter and validate events
const validEvents = events.filter(validateBlogPost);
if (validEvents.length === 0) return null;
// Return the most recent valid event (highest created_at)
const sortedEvents = validEvents.sort((a, b) => b.created_at - a.created_at);
return sortedEvents[0];
},
enabled: !!identifier,
});
}

View File

@@ -0,0 +1,326 @@
import { useParams, Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useBlogPostByDTag } from '@/hooks/useBlogPostByDTag';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useReactions, useReact } from '@/hooks/useReactions';
import { MarkdownContent } from '@/components/MarkdownContent';
import { CommentsSection } from '@/components/comments/CommentsSection';
import { ZapButton } from '@/components/ZapButton';
import { BookmarkButton } from '@/components/BookmarkButton';
import { ReadingTime } from '@/components/ReadingTime';
import { ArticleProgressBar } from '@/components/ArticleProgressBar';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
import { Calendar, Heart, Edit, ArrowLeft, Share2, Check, Code } from 'lucide-react';
import { genUserName } from '@/lib/genUserName';
import { calculateReadingTime } from '@/lib/calculateReadingTime';
import { useToast } from '@/hooks/useToast';
import { useState } from 'react';
import NotFound from '@/pages/NotFound';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
export default function ArticleByDTagPage() {
const { dtag } = useParams<{ dtag: string }>();
const { user } = useCurrentUser();
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const [jsonCopied, setJsonCopied] = useState(false);
const { data: post, isLoading } = useBlogPostByDTag(dtag || '');
const author = useAuthor(post?.pubkey || '');
const { data: reactions } = useReactions(post?.id || '', post?.pubkey || '');
const { mutate: react } = useReact();
const metadata = author.data?.metadata;
const displayName = metadata?.display_name || metadata?.name || genUserName(post?.pubkey || '');
// Check if the current user is the author of this post
const isPostAuthor = user?.pubkey === post?.pubkey;
const hasReacted = reactions?.likes.some(like => like.pubkey === user?.pubkey);
if (!dtag) {
return <NotFound />;
}
if (isLoading) {
return (
<div className="min-h-screen">
<div className="container max-w-4xl py-8 px-4 sm:px-6 lg:px-8">
<Skeleton className="h-8 w-24 mb-6" />
<div className="space-y-4">
<Skeleton className="h-12 w-3/4" />
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-64 w-full mt-8" />
</div>
</div>
</div>
);
}
if (!post) {
return <NotFound />;
}
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 identifier = post.tags.find(([name]) => name === 'd')?.[1] || '';
const hashtags = post.tags
.filter(([name]) => name === 't')
.map(([, value]) => value);
const date = publishedAt
? new Date(parseInt(publishedAt) * 1000)
: new Date(post.created_at * 1000);
// Calculate reading time
const readingTime = calculateReadingTime(post.content);
const handleReact = () => {
if (!user) return;
if (hasReacted) return;
react({ eventId: post.id, eventAuthor: post.pubkey });
};
const handleShare = async () => {
try {
const articleUrl = window.location.href;
await navigator.clipboard.writeText(articleUrl);
setCopied(true);
toast({
title: "Link copied!",
description: "Article link copied to clipboard",
});
setTimeout(() => setCopied(false), 2000);
} catch {
toast({
title: "Failed to copy",
description: "Could not copy link to clipboard",
variant: "destructive",
});
}
};
const handleCopyJson = async () => {
if (!post) return;
try {
const jsonString = JSON.stringify(post, null, 2);
await navigator.clipboard.writeText(jsonString);
setJsonCopied(true);
toast({
title: "JSON copied!",
description: "Raw event data copied to clipboard",
});
setTimeout(() => setJsonCopied(false), 2000);
} catch {
toast({
title: "Failed to copy",
description: "Could not copy JSON to clipboard",
variant: "destructive",
});
}
};
return (
<div className="min-h-screen">
{/* Sticky progress bar */}
<ArticleProgressBar />
<article className="container max-w-4xl py-8 px-4 sm:px-6 lg:px-8">
{/* Back button */}
<Button
variant="ghost"
size="sm"
onClick={() => window.history.back()}
className="mb-6"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
{/* Post header */}
<header className="space-y-6 mb-8">
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold leading-tight">
{title}
</h1>
{summary && (
<p className="text-lg sm:text-xl text-muted-foreground">
{summary}
</p>
)}
{/* Reading time */}
<ReadingTime minutes={readingTime} />
{/* Author info and metadata */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<Link to={`/${nip19.npubEncode(post.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>
</Avatar>
<div>
<div className="font-semibold">{displayName}</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-3 w-3" />
<time dateTime={date.toISOString()}>
{date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
</div>
</div>
</Link>
{isPostAuthor && (
<Button
variant="outline"
size="sm"
asChild
>
<Link to={`/edit/${identifier}`}>
<Edit className="h-4 w-4 mr-2" />
Edit
</Link>
</Button>
)}
</div>
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-2">
{hashtags.map((tag) => (
<Badge key={tag} variant="secondary">
#{tag}
</Badge>
))}
</div>
)}
</header>
{/* Cover image */}
{image && (
<div className="mb-8 rounded-lg overflow-hidden">
<img
src={image}
alt={title}
className="w-full h-auto max-h-[500px] object-cover"
/>
</div>
)}
{/* Post content */}
<div className="mb-12">
<MarkdownContent content={post.content} />
</div>
<Separator className="my-8" />
{/* Actions */}
<div className="flex flex-wrap items-center gap-4 mb-12">
<Button
variant={hasReacted ? "default" : "outline"}
onClick={handleReact}
disabled={!user || hasReacted}
className="gap-2"
>
<Heart className={`h-4 w-4 ${hasReacted ? 'fill-current' : ''}`} />
<span className="text-xs">
{reactions?.likeCount || 0}
</span>
</Button>
<ZapButton
target={post}
showCount={true}
/>
<Button
variant="outline"
onClick={handleShare}
className="gap-2"
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Share2 className="h-4 w-4" />
)}
<span className="text-xs">Share</span>
</Button>
<BookmarkButton
articleCoordinate={`${post.kind}:${post.pubkey}:${identifier}`}
variant="outline"
size="default"
showText={true}
/>
<Dialog>
<DialogTrigger asChild>
<Button
variant="outline"
className="gap-2"
>
<Code className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Raw Event Data</DialogTitle>
<DialogDescription>
NIP-23 blog post event (kind {post.kind})
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[60vh] w-full rounded-md border p-4">
<pre className="text-xs font-mono">
{JSON.stringify(post, null, 2)}
</pre>
</ScrollArea>
<div className="flex justify-end">
<Button
variant="outline"
onClick={handleCopyJson}
className="gap-2"
>
{jsonCopied ? (
<>
<Check className="h-4 w-4" />
Copied!
</>
) : (
<>
<Code className="h-4 w-4" />
Copy JSON
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Separator className="my-8" />
{/* Comments section */}
<CommentsSection
root={post}
/>
</article>
</div>
);
}

View File

@@ -15,18 +15,28 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
import { Calendar, Heart, Edit, ArrowLeft, Share2, Check } from 'lucide-react';
import { Calendar, Heart, Edit, ArrowLeft, Share2, Check, Code } from 'lucide-react';
import { genUserName } from '@/lib/genUserName';
import { calculateReadingTime } from '@/lib/calculateReadingTime';
import { useToast } from '@/hooks/useToast';
import { useState } from 'react';
import NotFound from '@/pages/NotFound';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
export default function BlogPostPage() {
const { nip19: naddr } = useParams<{ nip19: string }>();
const { user } = useCurrentUser();
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const [jsonCopied, setJsonCopied] = useState(false);
// Decode naddr
let pubkey = '';
@@ -123,6 +133,26 @@ export default function BlogPostPage() {
}
};
const handleCopyJson = async () => {
if (!post) return;
try {
const jsonString = JSON.stringify(post, null, 2);
await navigator.clipboard.writeText(jsonString);
setJsonCopied(true);
toast({
title: "JSON copied!",
description: "Raw event data copied to clipboard",
});
setTimeout(() => setJsonCopied(false), 2000);
} catch {
toast({
title: "Failed to copy",
description: "Could not copy JSON to clipboard",
variant: "destructive",
});
}
};
return (
<div className="min-h-screen">
{/* Sticky progress bar */}
@@ -258,6 +288,50 @@ export default function BlogPostPage() {
size="default"
showText={true}
/>
<Dialog>
<DialogTrigger asChild>
<Button
variant="outline"
className="gap-2"
>
<Code className="h-4 w-4" />
<span className="text-xs">View JSON</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Raw Event Data</DialogTitle>
<DialogDescription>
NIP-23 blog post event (kind {post.kind})
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[60vh] w-full rounded-md border p-4">
<pre className="text-xs font-mono">
{JSON.stringify(post, null, 2)}
</pre>
</ScrollArea>
<div className="flex justify-end">
<Button
variant="outline"
onClick={handleCopyJson}
className="gap-2"
>
{jsonCopied ? (
<>
<Check className="h-4 w-4" />
Copied!
</>
) : (
<>
<Code className="h-4 w-4" />
Copy JSON
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Separator className="my-8" />