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 */}
+
+
+ {/* Cover image */}
+ {image && (
+
+

+
+ )}
+
+ {/* Post content */}
+
+
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 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}
/>
+
+