mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-06-05 01:51:11 +02:00
bookmarks nip-51 implementation pt1
This commit is contained in:
@@ -6,6 +6,7 @@ import BlogHomePage from "./pages/BlogHomePage";
|
||||
import CreatePostPage from "./pages/CreatePostPage";
|
||||
import EditPostPage from "./pages/EditPostPage";
|
||||
import SearchResultsPage from "./pages/SearchResultsPage";
|
||||
import { BookmarksPage } from "./pages/BookmarksPage";
|
||||
import { NIP19Page } from "./pages/NIP19Page";
|
||||
import NotFound from "./pages/NotFound";
|
||||
|
||||
@@ -19,6 +20,7 @@ export function AppRouter() {
|
||||
<Route path="/create" element={<CreatePostPage />} />
|
||||
<Route path="/edit/:identifier" element={<EditPostPage />} />
|
||||
<Route path="/search" element={<SearchResultsPage />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
{/* 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 */}
|
||||
|
||||
89
src/components/BookmarkButton.tsx
Normal file
89
src/components/BookmarkButton.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Bookmark } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToggleBookmark } from '@/hooks/useToggleBookmark';
|
||||
import { useBookmarks } from '@/hooks/useBookmarks';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BookmarkButtonProps {
|
||||
articleCoordinate: string;
|
||||
className?: string;
|
||||
variant?: 'default' | 'ghost' | 'outline';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button component to bookmark/unbookmark articles using NIP-51.
|
||||
* Shows filled bookmark icon when bookmarked, outline when not.
|
||||
*/
|
||||
export function BookmarkButton({
|
||||
articleCoordinate,
|
||||
className,
|
||||
variant = 'ghost',
|
||||
size = 'icon',
|
||||
showText = false,
|
||||
}: BookmarkButtonProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: bookmarks = [] } = useBookmarks();
|
||||
const { mutate: toggleBookmark, isPending } = useToggleBookmark();
|
||||
const { toast } = useToast();
|
||||
|
||||
const isBookmarked = bookmarks.includes(articleCoordinate);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!user) {
|
||||
toast({
|
||||
title: 'Login required',
|
||||
description: 'Please log in to bookmark articles',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toggleBookmark(
|
||||
{ articleCoordinate },
|
||||
{
|
||||
onSuccess: ({ isBookmarked: newState }) => {
|
||||
toast({
|
||||
title: newState ? 'Bookmarked!' : 'Removed from bookmarks',
|
||||
description: newState
|
||||
? 'Article added to your bookmarks'
|
||||
: 'Article removed from your bookmarks',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to update bookmark',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={handleClick}
|
||||
disabled={isPending || !user}
|
||||
className={cn(className)}
|
||||
title={isBookmarked ? 'Remove bookmark' : 'Bookmark this article'}
|
||||
>
|
||||
<Bookmark
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
isBookmarked && 'fill-current'
|
||||
)}
|
||||
/>
|
||||
{showText && (
|
||||
<span className="ml-2">
|
||||
{isBookmarked ? 'Bookmarked' : 'Bookmark'}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
85
src/hooks/useBookmarkedArticles.ts
Normal file
85
src/hooks/useBookmarkedArticles.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useBookmarks } from './useBookmarks';
|
||||
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 the full blog post events for bookmarked articles.
|
||||
* Parses article coordinates and queries for the actual events.
|
||||
*/
|
||||
export function useBookmarkedArticles() {
|
||||
const { nostr } = useNostr();
|
||||
const { data: bookmarks = [], isLoading: isLoadingBookmarks } = useBookmarks();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['bookmarked-articles', bookmarks],
|
||||
queryFn: async (c) => {
|
||||
if (bookmarks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
|
||||
|
||||
// Parse article coordinates (format: kind:pubkey:d-tag)
|
||||
const parsedCoordinates = bookmarks
|
||||
.map((coord) => {
|
||||
const parts = coord.split(':');
|
||||
if (parts.length !== 3) return null;
|
||||
const [kind, pubkey, dTag] = parts;
|
||||
return { kind: parseInt(kind), pubkey, dTag };
|
||||
})
|
||||
.filter((coord): coord is { kind: number; pubkey: string; dTag: string } =>
|
||||
coord !== null && coord.kind === 30023
|
||||
);
|
||||
|
||||
if (parsedCoordinates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Query for all bookmarked articles
|
||||
// Group by author to make efficient queries
|
||||
const authorGroups = new Map<string, string[]>();
|
||||
parsedCoordinates.forEach(({ pubkey, dTag }) => {
|
||||
if (!authorGroups.has(pubkey)) {
|
||||
authorGroups.set(pubkey, []);
|
||||
}
|
||||
authorGroups.get(pubkey)!.push(dTag);
|
||||
});
|
||||
|
||||
// Create filter for each author
|
||||
const filters = Array.from(authorGroups.entries()).map(([pubkey, dTags]) => ({
|
||||
kinds: [30023],
|
||||
authors: [pubkey],
|
||||
'#d': dTags,
|
||||
}));
|
||||
|
||||
const events = await nostr.query(filters, { signal });
|
||||
|
||||
// Filter and validate events
|
||||
const validArticles = events.filter(validateBlogPost);
|
||||
|
||||
// Sort by created_at descending (newest first)
|
||||
return validArticles.sort((a, b) => b.created_at - a.created_at);
|
||||
},
|
||||
enabled: !isLoadingBookmarks && bookmarks.length > 0,
|
||||
staleTime: 60000, // Consider data fresh for 1 minute
|
||||
});
|
||||
}
|
||||
88
src/hooks/useBookmarks.ts
Normal file
88
src/hooks/useBookmarks.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
|
||||
/**
|
||||
* Hook to fetch the user's bookmarks list (NIP-51 kind 10003).
|
||||
* Returns an array of 'a' tags representing bookmarked articles.
|
||||
*/
|
||||
export function useBookmarks() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['bookmarks', user?.pubkey],
|
||||
queryFn: async (c) => {
|
||||
if (!user?.pubkey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
|
||||
|
||||
// Query for kind 10003 (Bookmarks) events by the current user
|
||||
const events = await nostr.query(
|
||||
[
|
||||
{
|
||||
kinds: [10003],
|
||||
authors: [user.pubkey],
|
||||
limit: 1,
|
||||
},
|
||||
],
|
||||
{ signal }
|
||||
);
|
||||
|
||||
// Get the most recent bookmark list event
|
||||
const bookmarkEvent = events.sort((a, b) => b.created_at - a.created_at)[0];
|
||||
|
||||
if (!bookmarkEvent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract 'a' tags (addressable event references for articles)
|
||||
const bookmarkedArticles = bookmarkEvent.tags
|
||||
.filter((tag) => tag[0] === 'a')
|
||||
.map((tag) => tag[1]);
|
||||
|
||||
return bookmarkedArticles;
|
||||
},
|
||||
enabled: !!user?.pubkey,
|
||||
staleTime: 30000, // Consider data fresh for 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch the full bookmark event for the current user.
|
||||
* Useful for getting all bookmark types (notes, articles, URLs, hashtags).
|
||||
*/
|
||||
export function useBookmarkEvent() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['bookmark-event', user?.pubkey],
|
||||
queryFn: async (c) => {
|
||||
if (!user?.pubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
|
||||
|
||||
const events = await nostr.query(
|
||||
[
|
||||
{
|
||||
kinds: [10003],
|
||||
authors: [user.pubkey],
|
||||
limit: 1,
|
||||
},
|
||||
],
|
||||
{ signal }
|
||||
);
|
||||
|
||||
const bookmarkEvent = events.sort((a, b) => b.created_at - a.created_at)[0];
|
||||
|
||||
return bookmarkEvent || null;
|
||||
},
|
||||
enabled: !!user?.pubkey,
|
||||
staleTime: 30000,
|
||||
});
|
||||
}
|
||||
61
src/hooks/useToggleBookmark.ts
Normal file
61
src/hooks/useToggleBookmark.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useBookmarkEvent } from './useBookmarks';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
|
||||
/**
|
||||
* Hook to toggle a bookmark for an article.
|
||||
* Publishes a kind 10003 event with the updated bookmarks list.
|
||||
*/
|
||||
export function useToggleBookmark() {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: bookmarkEvent } = useBookmarkEvent();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ articleCoordinate }: { articleCoordinate: string }) => {
|
||||
if (!user) {
|
||||
throw new Error('Must be logged in to bookmark');
|
||||
}
|
||||
|
||||
// Get existing bookmarks from the current bookmark event
|
||||
const existingTags = bookmarkEvent?.tags || [];
|
||||
|
||||
// Filter out 'a' tags to get articles
|
||||
const articleTags = existingTags.filter((tag) => tag[0] === 'a');
|
||||
const otherTags = existingTags.filter((tag) => tag[0] !== 'a');
|
||||
|
||||
// Check if this article is already bookmarked
|
||||
const isBookmarked = articleTags.some((tag) => tag[1] === articleCoordinate);
|
||||
|
||||
let newArticleTags: string[][];
|
||||
|
||||
if (isBookmarked) {
|
||||
// Remove the bookmark
|
||||
newArticleTags = articleTags.filter((tag) => tag[1] !== articleCoordinate);
|
||||
} else {
|
||||
// Add the bookmark
|
||||
newArticleTags = [...articleTags, ['a', articleCoordinate]];
|
||||
}
|
||||
|
||||
// Combine all tags
|
||||
const allTags = [...otherTags, ...newArticleTags];
|
||||
|
||||
// Create and publish the updated bookmark event
|
||||
const event = await publishEvent({
|
||||
kind: 10003,
|
||||
content: '',
|
||||
tags: allTags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
return { event, isBookmarked: !isBookmarked };
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate bookmark queries to refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['bookmarks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['bookmark-event'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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 { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -203,6 +204,13 @@ export default function BlogPostPage() {
|
||||
target={post}
|
||||
showCount={true}
|
||||
/>
|
||||
|
||||
<BookmarkButton
|
||||
articleCoordinate={`${post.kind}:${post.pubkey}:${identifier}`}
|
||||
variant="outline"
|
||||
size="default"
|
||||
showText={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="my-8" />
|
||||
|
||||
107
src/pages/BookmarksPage.tsx
Normal file
107
src/pages/BookmarksPage.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { BlogLayout } from '@/components/BlogLayout';
|
||||
import { ArticlePreview } from '@/components/ArticlePreview';
|
||||
import { RelaySelector } from '@/components/RelaySelector';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useBookmarkedArticles } from '@/hooks/useBookmarkedArticles';
|
||||
import { useBookmarks } from '@/hooks/useBookmarks';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Bookmark } from 'lucide-react';
|
||||
|
||||
export function BookmarksPage() {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: bookmarks = [], isLoading: isLoadingBookmarks } = useBookmarks();
|
||||
const { data: articles = [], isLoading: isLoadingArticles } = useBookmarkedArticles();
|
||||
|
||||
const isLoading = isLoadingBookmarks || isLoadingArticles;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-5xl">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Bookmark className="h-8 w-8" />
|
||||
<h1 className="text-4xl font-bold">Bookmarks</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Articles you've saved for later reading
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Show login prompt if not logged in */}
|
||||
{!user ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 px-8 text-center">
|
||||
<div className="max-w-sm mx-auto space-y-6">
|
||||
<Bookmark className="h-16 w-16 mx-auto text-muted-foreground" />
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold">Login to View Bookmarks</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Sign in to see your saved articles
|
||||
</p>
|
||||
</div>
|
||||
<LoginArea className="flex justify-center" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isLoading ? (
|
||||
/* Loading state */
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-6 space-y-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-2 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : bookmarks.length === 0 ? (
|
||||
/* Empty state */
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 px-8 text-center">
|
||||
<div className="max-w-sm mx-auto space-y-6">
|
||||
<Bookmark className="h-16 w-16 mx-auto text-muted-foreground" />
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold">No Bookmarks Yet</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Start bookmarking articles to see them here. Look for the bookmark button on articles you'd like to save.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : articles.length === 0 ? (
|
||||
/* No articles found from bookmarks */
|
||||
<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">
|
||||
Couldn't load bookmarked articles. Try switching to a different relay?
|
||||
</p>
|
||||
<RelaySelector className="w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
/* Articles grid */
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{articles.map((article) => (
|
||||
<ArticlePreview
|
||||
key={article.id}
|
||||
post={article}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user