From b839de7a3b023cbe03e57fabc0f515221f166282 Mon Sep 17 00:00:00 2001 From: highperfocused Date: Tue, 7 Oct 2025 21:34:55 +0200 Subject: [PATCH] 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. --- src/AppRouter.tsx | 3 + src/hooks/useBlogPostByDTag.ts | 58 ++++++ src/pages/ArticleByDTagPage.tsx | 326 ++++++++++++++++++++++++++++++++ src/pages/BlogPostPage.tsx | 76 +++++++- 4 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useBlogPostByDTag.ts create mode 100644 src/pages/ArticleByDTagPage.tsx diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 1244a47..5b5b084 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -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() { } /> {/* NIP-05 profile route (e.g., /p/alice@example.com) */} } /> + {/* Article by d-tag route (e.g., /article/my-article-slug) */} + } /> {/* NIP-19 route for all Nostr identifiers (npub, nprofile, naddr, note, nevent) */} } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} diff --git a/src/hooks/useBlogPostByDTag.ts b/src/hooks/useBlogPostByDTag.ts new file mode 100644 index 0000000..a4bd10c --- /dev/null +++ b/src/hooks/useBlogPostByDTag.ts @@ -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, + }); +} diff --git a/src/pages/ArticleByDTagPage.tsx b/src/pages/ArticleByDTagPage.tsx new file mode 100644 index 0000000..b915c40 --- /dev/null +++ b/src/pages/ArticleByDTagPage.tsx @@ -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 ; + } + + if (isLoading) { + return ( +
+
+ +
+ + + +
+
+
+ ); + } + + if (!post) { + return ; + } + + 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 ( +
+ {/* Sticky progress bar */} + + +
+ {/* Back button */} + + + {/* Post header */} +
+

+ {title} +

+ + {summary && ( +

+ {summary} +

+ )} + + {/* Reading time */} + + + {/* Author info and metadata */} +
+ + + + {displayName[0]?.toUpperCase()} + +
+
{displayName}
+
+ + +
+
+ + + {isPostAuthor && ( + + )} +
+ + {hashtags.length > 0 && ( +
+ {hashtags.map((tag) => ( + + #{tag} + + ))} +
+ )} +
+ + {/* Cover image */} + {image && ( +
+ {title} +
+ )} + + {/* Post content */} +
+ +
+ + + + {/* Actions */} +
+ + + + + + + + + + + + + + + Raw Event Data + + NIP-23 blog post event (kind {post.kind}) + + + +
+                  {JSON.stringify(post, null, 2)}
+                
+
+
+ +
+
+
+
+ + + + {/* Comments section */} + +
+
+ ); +} diff --git a/src/pages/BlogPostPage.tsx b/src/pages/BlogPostPage.tsx index 323d117..db4b62c 100644 --- a/src/pages/BlogPostPage.tsx +++ b/src/pages/BlogPostPage.tsx @@ -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 (
{/* Sticky progress bar */} @@ -258,6 +288,50 @@ export default function BlogPostPage() { size="default" showText={true} /> + + + + + + + + Raw Event Data + + NIP-23 blog post event (kind {post.kind}) + + + +
+                  {JSON.stringify(post, null, 2)}
+                
+
+
+ +
+
+