From 612d5595d7581044bf2723048519cc8d17a449b8 Mon Sep 17 00:00:00 2001 From: Danish Arora Date: Fri, 5 Sep 2025 17:24:29 +0530 Subject: [PATCH] feat: bookmarks --- src/App.tsx | 2 + src/components/CommentCard.tsx | 179 ++++++++++++++++++ src/components/Header.tsx | 36 ++-- src/components/PostCard.tsx | 89 +++++++-- src/components/PostDetail.tsx | 154 ++++----------- src/components/ui/bookmark-button.tsx | 88 +++++++++ src/components/ui/bookmark-card.tsx | 173 +++++++++++++++++ src/hooks/core/useBookmarks.ts | 256 +++++++++++++++++++++++++ src/hooks/core/useForumData.ts | 2 +- src/hooks/index.ts | 5 + src/lib/database/LocalDatabase.ts | 100 +++++++++- src/lib/database/schema.ts | 12 +- src/lib/services/BookmarkService.ts | 172 +++++++++++++++++ src/pages/BookmarksPage.tsx | 260 ++++++++++++++++++++++++++ src/pages/FeedPage.tsx | 8 +- src/types/forum.ts | 31 +++ 16 files changed, 1413 insertions(+), 154 deletions(-) create mode 100644 src/components/CommentCard.tsx create mode 100644 src/components/ui/bookmark-button.tsx create mode 100644 src/components/ui/bookmark-card.tsx create mode 100644 src/hooks/core/useBookmarks.ts create mode 100644 src/lib/services/BookmarkService.ts create mode 100644 src/pages/BookmarksPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 6af3a66..a51f247 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import NotFound from './pages/NotFound'; import Dashboard from './pages/Dashboard'; import Index from './pages/Index'; import ProfilePage from './pages/ProfilePage'; +import BookmarksPage from './pages/BookmarksPage'; import { appkitConfig } from './lib/wallet/config'; import { WagmiProvider } from 'wagmi'; import { config } from './lib/wallet/config'; @@ -50,6 +51,7 @@ const App = () => ( } /> } /> } /> + } /> } /> diff --git a/src/components/CommentCard.tsx b/src/components/CommentCard.tsx new file mode 100644 index 0000000..6b4225f --- /dev/null +++ b/src/components/CommentCard.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { ArrowUp, ArrowDown, Clock, Shield, UserX } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { Comment } from '@/types/forum'; +import { + useForumActions, + usePermissions, + useUserVotes, + useCommentBookmark, +} from '@/hooks'; +import { Button } from '@/components/ui/button'; +import { BookmarkButton } from '@/components/ui/bookmark-button'; +import { AuthorDisplay } from '@/components/ui/author-display'; +import { usePending, usePendingVote } from '@/hooks/usePending'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +interface CommentCardProps { + comment: Comment; + postId: string; + cellId?: string; + canModerate: boolean; + onModerateComment: (commentId: string) => void; + onModerateUser: (userAddress: string) => void; +} + +// Extracted child component to respect Rules of Hooks +const PendingBadge: React.FC<{ id: string }> = ({ id }) => { + const { isPending } = usePending(id); + if (!isPending) return null; + return ( + <> + + + syncing… + + + ); +}; + +const CommentCard: React.FC = ({ + comment, + postId, + cellId, + canModerate, + onModerateComment, + onModerateUser, +}) => { + const { voteComment, isVoting } = useForumActions(); + const { canVote } = usePermissions(); + const userVotes = useUserVotes(); + const { + isBookmarked, + loading: bookmarkLoading, + toggleBookmark, + } = useCommentBookmark(comment, postId); + + const commentVotePending = usePendingVote(comment.id); + + const handleVoteComment = async (isUpvote: boolean) => { + await voteComment(comment.id, isUpvote); + }; + + const handleBookmark = async () => { + await toggleBookmark(); + }; + + const getCommentVoteType = () => { + return userVotes.getCommentVoteType(comment.id); + }; + + return ( +
+
+
+ + {comment.voteScore} + + {commentVotePending.isPending && ( + syncing… + )} +
+ +
+
+
+ + + + + {formatDistanceToNow(new Date(comment.timestamp), { + addSuffix: true, + })} + + +
+ +
+ +

{comment.content}

+ +
+ {canModerate && !comment.moderated && ( + + + + + +

Moderate comment

+
+
+ )} + {cellId && canModerate && ( + + + + + +

Moderate user

+
+
+ )} +
+
+
+
+ ); +}; + +export default CommentCard; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index fef98c6..bed914c 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -18,6 +18,7 @@ import { Home, Grid3X3, User, + Bookmark, } from 'lucide-react'; import { Tooltip, @@ -197,17 +198,30 @@ const Header = () => { Cells {isConnected && ( - - - Profile - + <> + + + Bookmarks + + + + Profile + + )} diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index 0396643..0b20bf0 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { ArrowUp, ArrowDown, MessageSquare } from 'lucide-react'; +import { ArrowUp, ArrowDown, MessageSquare, Clipboard } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { Post } from '@/types/forum'; import { @@ -8,10 +8,13 @@ import { usePermissions, useUserVotes, useForumData, + usePostBookmark, } from '@/hooks'; import { RelevanceIndicator } from '@/components/ui/relevance-indicator'; import { AuthorDisplay } from '@/components/ui/author-display'; +import { BookmarkButton } from '@/components/ui/bookmark-button'; import { usePending, usePendingVote } from '@/hooks/usePending'; +import { useToast } from '@/components/ui/use-toast'; interface PostCardProps { post: Post; @@ -19,11 +22,16 @@ interface PostCardProps { } const PostCard: React.FC = ({ post, commentCount = 0 }) => { - // ✅ Use reactive hooks instead of direct context access const { cellsWithStats } = useForumData(); const { votePost, isVoting } = useForumActions(); const { canVote } = usePermissions(); const userVotes = useUserVotes(); + const { + isBookmarked, + loading: bookmarkLoading, + toggleBookmark, + } = usePostBookmark(post, post.cellId); + const { toast } = useToast(); // ✅ Get pre-computed cell data const cell = cellsWithStats.find(c => c.id === post.cellId); @@ -54,6 +62,42 @@ const PostCard: React.FC = ({ post, commentCount = 0 }) => { await votePost(post.id, isUpvote); }; + const handleBookmark = async (e?: React.MouseEvent) => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + await toggleBookmark(); + }; + + const handleShare = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const postUrl = `${window.location.origin}/post/${post.id}`; + + try { + await navigator.clipboard.writeText(postUrl); + toast({ + title: 'Link copied!', + description: 'Post link has been copied to your clipboard.', + }); + } catch { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = postUrl; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + + toast({ + title: 'Link copied!', + description: 'Post link has been copied to your clipboard.', + }); + } + }; + return (
@@ -147,22 +191,33 @@ const PostCard: React.FC = ({ post, commentCount = 0 }) => {

{/* Post actions */} -
-
- - {commentCount} comments +
+
+
+ + {commentCount} comments +
+ {isPending && ( + + syncing… + + )} +
- {isPending && ( - - syncing… - - )} - - +
diff --git a/src/components/PostDetail.tsx b/src/components/PostDetail.tsx index ed4de00..a6d78c0 100644 --- a/src/components/PostDetail.tsx +++ b/src/components/PostDetail.tsx @@ -6,6 +6,7 @@ import { useForumActions, usePermissions, useUserVotes, + usePostBookmark, } from '@/hooks'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; @@ -17,33 +18,14 @@ import { MessageCircle, Send, Loader2, - Shield, - UserX, } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { RelevanceIndicator } from './ui/relevance-indicator'; import { AuthorDisplay } from './ui/author-display'; +import { BookmarkButton } from './ui/bookmark-button'; +import CommentCard from './CommentCard'; import { usePending, usePendingVote } from '@/hooks/usePending'; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip'; - -// Extracted child component to respect Rules of Hooks -const PendingBadge: React.FC<{ id: string }> = ({ id }) => { - const { isPending } = usePending(id); - if (!isPending) return null; - return ( - <> - - - syncing… - - - ); -}; const PostDetail = () => { const { postId } = useParams<{ postId: string }>(); @@ -55,7 +37,6 @@ const PostDetail = () => { const { createComment, votePost, - voteComment, moderateComment, moderateUser, isCreatingComment, @@ -63,6 +44,11 @@ const PostDetail = () => { } = useForumActions(); const { canVote, canComment, canModerate } = usePermissions(); const userVotes = useUserVotes(); + const { + isBookmarked, + loading: bookmarkLoading, + toggleBookmark, + } = usePostBookmark(post!, post?.cellId); // ✅ Move ALL hook calls to the top, before any conditional logic const postPending = usePending(post?.id); @@ -118,9 +104,12 @@ const PostDetail = () => { await votePost(post.id, isUpvote); }; - const handleVoteComment = async (commentId: string, isUpvote: boolean) => { - // ✅ Permission checking handled in hook - await voteComment(commentId, isUpvote); + const handleBookmark = async (e?: React.MouseEvent) => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + await toggleBookmark(); }; // ✅ Get vote status from hooks @@ -128,10 +117,6 @@ const PostDetail = () => { const isPostUpvoted = postVoteType === 'upvote'; const isPostDownvoted = postVoteType === 'downvote'; - const getCommentVoteType = (commentId: string) => { - return userVotes.getCommentVoteType(commentId); - }; - const handleModerateComment = async (commentId: string) => { const reason = window.prompt('Enter a reason for moderation (optional):') || undefined; @@ -239,7 +224,17 @@ const PostDetail = () => { )}
-

{post.title}

+
+

{post.title}

+ +

{post.content}

@@ -316,98 +311,15 @@ const PostDetail = () => {
) : ( visibleComments.map(comment => ( -
-
-
- - {comment.voteScore} - -
- -
-
- - - - - {formatDistanceToNow(new Date(comment.timestamp), { - addSuffix: true, - })} - - -
-

{comment.content}

- {canModerate(cell?.id || '') && !comment.moderated && ( - - - - - -

Moderate comment

-
-
- )} - {post.cell && - canModerate(post.cell.id) && - comment.author !== post.author && ( - - - - - -

Moderate user

-
-
- )} - {comment.moderated && ( - - [Moderated] - - )} -
-
-
+ comment={comment} + postId={postId} + cellId={cell?.id} + canModerate={canModerate(cell?.id || '')} + onModerateComment={handleModerateComment} + onModerateUser={handleModerateUser} + /> )) )}
diff --git a/src/components/ui/bookmark-button.tsx b/src/components/ui/bookmark-button.tsx new file mode 100644 index 0000000..10209e6 --- /dev/null +++ b/src/components/ui/bookmark-button.tsx @@ -0,0 +1,88 @@ +import { Button } from '@/components/ui/button'; +import { Bookmark, BookmarkCheck } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface BookmarkButtonProps { + isBookmarked: boolean; + loading?: boolean; + onClick: (e?: React.MouseEvent) => void; + size?: 'sm' | 'lg'; + variant?: 'default' | 'ghost' | 'outline'; + className?: string; + showText?: boolean; +} + +export function BookmarkButton({ + isBookmarked, + loading = false, + onClick, + size = 'sm', + variant = 'ghost', + className, + showText = false, +}: BookmarkButtonProps) { + const sizeClasses = { + sm: 'h-8 w-8', + lg: 'h-10 w-10', + }; + + const iconSize = { + sm: 14, + lg: 18, + }; + + return ( + + ); +} + +interface BookmarkIndicatorProps { + isBookmarked: boolean; + className?: string; +} + +export function BookmarkIndicator({ + isBookmarked, + className, +}: BookmarkIndicatorProps) { + if (!isBookmarked) return null; + + return ( +
+ + Bookmarked +
+ ); +} diff --git a/src/components/ui/bookmark-card.tsx b/src/components/ui/bookmark-card.tsx new file mode 100644 index 0000000..6148da6 --- /dev/null +++ b/src/components/ui/bookmark-card.tsx @@ -0,0 +1,173 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Bookmark as BookmarkIcon, + MessageSquare, + FileText, + Trash2, + ExternalLink, +} from 'lucide-react'; +import { Bookmark, BookmarkType } from '@/types/forum'; +import { useUserDisplay } from '@/hooks'; +import { cn } from '@/lib/utils'; +import { formatDistanceToNow } from 'date-fns'; + +interface BookmarkCardProps { + bookmark: Bookmark; + onRemove: (bookmarkId: string) => void; + onNavigate?: (bookmark: Bookmark) => void; + className?: string; +} + +export function BookmarkCard({ + bookmark, + onRemove, + onNavigate, + className, +}: BookmarkCardProps) { + const authorInfo = useUserDisplay(bookmark.author || ''); + + const handleNavigate = () => { + if (onNavigate) { + onNavigate(bookmark); + } else { + // Default navigation behavior + if (bookmark.type === BookmarkType.POST) { + window.location.href = `/post/${bookmark.targetId}`; + } else if (bookmark.type === BookmarkType.COMMENT && bookmark.postId) { + window.location.href = `/post/${bookmark.postId}#comment-${bookmark.targetId}`; + } + } + }; + + const handleRemove = (e: React.MouseEvent) => { + e.stopPropagation(); + onRemove(bookmark.id); + }; + + return ( + + +
+
+ {bookmark.type === BookmarkType.POST ? ( + + ) : ( + + )} + + {bookmark.type === BookmarkType.POST ? 'Post' : 'Comment'} + +
+ +
+
+ + +
+ {/* Title/Content Preview */} +
+

+ {bookmark.title || 'Untitled'} +

+
+ + {/* Author and Metadata */} +
+
+ by + + {authorInfo.displayName} + +
+
+ + {formatDistanceToNow(new Date(bookmark.createdAt), { + addSuffix: true, + })} + + +
+
+ + {/* Additional Context */} + {bookmark.cellId && ( +
+ Cell: {bookmark.cellId} +
+ )} +
+
+
+ ); +} + +interface BookmarkListProps { + bookmarks: Bookmark[]; + onRemove: (bookmarkId: string) => void; + onNavigate?: (bookmark: Bookmark) => void; + emptyMessage?: string; + className?: string; +} + +export function BookmarkList({ + bookmarks, + onRemove, + onNavigate, + emptyMessage = 'No bookmarks yet', + className, +}: BookmarkListProps) { + if (bookmarks.length === 0) { + return ( +
+ +

+ {emptyMessage} +

+

+ Bookmark posts and comments you want to revisit later. Your bookmarks + are saved locally and won't be shared. +

+
+ ); + } + + return ( +
+ {bookmarks.map(bookmark => ( + + ))} +
+ ); +} diff --git a/src/hooks/core/useBookmarks.ts b/src/hooks/core/useBookmarks.ts new file mode 100644 index 0000000..1491bfd --- /dev/null +++ b/src/hooks/core/useBookmarks.ts @@ -0,0 +1,256 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Bookmark, BookmarkType, Post, Comment } from '@/types/forum'; +import { BookmarkService } from '@/lib/services/BookmarkService'; +import { useAuth } from '@/contexts/useAuth'; + +/** + * Hook for managing bookmarks + * Provides bookmark state and operations for the current user + */ +export function useBookmarks() { + const { currentUser } = useAuth(); + const [bookmarks, setBookmarks] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadBookmarks = useCallback(async () => { + if (!currentUser?.address) return; + + setLoading(true); + setError(null); + try { + const userBookmarks = await BookmarkService.getUserBookmarks( + currentUser.address + ); + setBookmarks(userBookmarks); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load bookmarks'); + } finally { + setLoading(false); + } + }, [currentUser?.address]); + + // Load user bookmarks when user changes + useEffect(() => { + if (currentUser?.address) { + loadBookmarks(); + } else { + setBookmarks([]); + } + }, [currentUser?.address, loadBookmarks]); + + const bookmarkPost = useCallback( + async (post: Post, cellId?: string) => { + if (!currentUser?.address) return false; + + try { + const isBookmarked = await BookmarkService.togglePostBookmark( + post, + currentUser.address, + cellId + ); + await loadBookmarks(); // Refresh the list + return isBookmarked; + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to bookmark post' + ); + return false; + } + }, + [currentUser?.address, loadBookmarks] + ); + + const bookmarkComment = useCallback( + async (comment: Comment, postId?: string) => { + if (!currentUser?.address) return false; + + try { + const isBookmarked = await BookmarkService.toggleCommentBookmark( + comment, + currentUser.address, + postId + ); + await loadBookmarks(); // Refresh the list + return isBookmarked; + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to bookmark comment' + ); + return false; + } + }, + [currentUser?.address, loadBookmarks] + ); + + const removeBookmark = useCallback( + async (bookmarkId: string) => { + try { + const bookmark = BookmarkService.getBookmark(bookmarkId); + if (bookmark) { + await BookmarkService.removeBookmark( + bookmark.type, + bookmark.targetId + ); + await loadBookmarks(); // Refresh the list + } + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to remove bookmark' + ); + } + }, + [loadBookmarks] + ); + + const isPostBookmarked = useCallback( + (postId: string) => { + if (!currentUser?.address) return false; + return BookmarkService.isPostBookmarked(currentUser.address, postId); + }, + [currentUser?.address] + ); + + const isCommentBookmarked = useCallback( + (commentId: string) => { + if (!currentUser?.address) return false; + return BookmarkService.isCommentBookmarked( + currentUser.address, + commentId + ); + }, + [currentUser?.address] + ); + + const getBookmarksByType = useCallback( + (type: BookmarkType) => { + return bookmarks.filter(bookmark => bookmark.type === type); + }, + [bookmarks] + ); + + const clearAllBookmarks = useCallback(async () => { + if (!currentUser?.address) return; + + try { + await BookmarkService.clearUserBookmarks(currentUser.address); + setBookmarks([]); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to clear bookmarks' + ); + } + }, [currentUser?.address]); + + return { + bookmarks, + loading, + error, + bookmarkPost, + bookmarkComment, + removeBookmark, + isPostBookmarked, + isCommentBookmarked, + getBookmarksByType, + clearAllBookmarks, + refreshBookmarks: loadBookmarks, + }; +} + +/** + * Hook for bookmarking a specific post + * Provides bookmark state and toggle function for a single post + */ +export function usePostBookmark(post: Post, cellId?: string) { + const { currentUser } = useAuth(); + const [isBookmarked, setIsBookmarked] = useState(false); + const [loading, setLoading] = useState(false); + + // Check initial bookmark status + useEffect(() => { + if (currentUser?.address) { + const bookmarked = BookmarkService.isPostBookmarked( + currentUser.address, + post.id + ); + setIsBookmarked(bookmarked); + } else { + setIsBookmarked(false); + } + }, [currentUser?.address, post.id]); + + const toggleBookmark = useCallback(async () => { + if (!currentUser?.address) return false; + + setLoading(true); + try { + const newBookmarkStatus = await BookmarkService.togglePostBookmark( + post, + currentUser.address, + cellId + ); + setIsBookmarked(newBookmarkStatus); + return newBookmarkStatus; + } catch (err) { + console.error('Failed to toggle post bookmark:', err); + return false; + } finally { + setLoading(false); + } + }, [currentUser?.address, post, cellId]); + + return { + isBookmarked, + loading, + toggleBookmark, + }; +} + +/** + * Hook for bookmarking a specific comment + * Provides bookmark state and toggle function for a single comment + */ +export function useCommentBookmark(comment: Comment, postId?: string) { + const { currentUser } = useAuth(); + const [isBookmarked, setIsBookmarked] = useState(false); + const [loading, setLoading] = useState(false); + + // Check initial bookmark status + useEffect(() => { + if (currentUser?.address) { + const bookmarked = BookmarkService.isCommentBookmarked( + currentUser.address, + comment.id + ); + setIsBookmarked(bookmarked); + } else { + setIsBookmarked(false); + } + }, [currentUser?.address, comment.id]); + + const toggleBookmark = useCallback(async () => { + if (!currentUser?.address) return false; + + setLoading(true); + try { + const newBookmarkStatus = await BookmarkService.toggleCommentBookmark( + comment, + currentUser.address, + postId + ); + setIsBookmarked(newBookmarkStatus); + return newBookmarkStatus; + } catch (err) { + console.error('Failed to toggle comment bookmark:', err); + return false; + } finally { + setLoading(false); + } + }, [currentUser?.address, comment, postId]); + + return { + isBookmarked, + loading, + toggleBookmark, + }; +} diff --git a/src/hooks/core/useForumData.ts b/src/hooks/core/useForumData.ts index f99736a..e19a453 100644 --- a/src/hooks/core/useForumData.ts +++ b/src/hooks/core/useForumData.ts @@ -338,7 +338,7 @@ export function useForumData(): ForumData { if (!organized[comment.postId]) { organized[comment.postId] = []; } - organized[comment.postId].push(comment); + organized[comment.postId]!.push(comment); }); return organized; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d1b4ffa..39375ec 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,6 +2,11 @@ export { useForumData } from './core/useForumData'; export { useAuth } from './core/useAuth'; export { useUserDisplay } from './core/useUserDisplay'; +export { + useBookmarks, + usePostBookmark, + useCommentBookmark, +} from './core/useBookmarks'; // Core types export type { diff --git a/src/lib/database/LocalDatabase.ts b/src/lib/database/LocalDatabase.ts index 669c1c0..d98288d 100644 --- a/src/lib/database/LocalDatabase.ts +++ b/src/lib/database/LocalDatabase.ts @@ -17,6 +17,7 @@ import { MessageValidator } from '@/lib/utils/MessageValidator'; import { EVerificationStatus, User } from '@/types/identity'; import { DelegationInfo } from '@/lib/delegation/types'; import { openLocalDB, STORE, StoreName } from '@/lib/database/schema'; +import { Bookmark, BookmarkCache } from '@/types/forum'; export interface LocalDatabaseCache { cells: CellCache; @@ -25,6 +26,7 @@ export interface LocalDatabaseCache { votes: VoteCache; moderations: { [targetId: string]: ModerateMessage }; userIdentities: UserIdentityCache; + bookmarks: BookmarkCache; } /** @@ -47,6 +49,7 @@ export class LocalDatabase { votes: {}, moderations: {}, userIdentities: {}, + bookmarks: {}, }; constructor() { @@ -109,6 +112,7 @@ export class LocalDatabase { this.cache.votes = {}; this.cache.moderations = {}; this.cache.userIdentities = {}; + this.cache.bookmarks = {}; } private storeMessage(message: OpchanMessage): void { @@ -204,13 +208,14 @@ export class LocalDatabase { private async hydrateFromIndexedDB(): Promise { if (!this.db) return; - const [cells, posts, comments, votes, moderations, identities]: [ + const [cells, posts, comments, votes, moderations, identities, bookmarks]: [ CellMessage[], PostMessage[], CommentMessage[], (VoteMessage & { key: string })[], ModerateMessage[], ({ address: string } & UserIdentityCache[string])[], + Bookmark[], ] = await Promise.all([ this.getAllFromStore(STORE.CELLS), this.getAllFromStore(STORE.POSTS), @@ -220,6 +225,7 @@ export class LocalDatabase { this.getAllFromStore<{ address: string } & UserIdentityCache[string]>( STORE.USER_IDENTITIES ), + this.getAllFromStore(STORE.BOOKMARKS), ]); this.cache.cells = Object.fromEntries(cells.map(c => [c.id, c])); @@ -241,6 +247,7 @@ export class LocalDatabase { return [address, record]; }) ); + this.cache.bookmarks = Object.fromEntries(bookmarks.map(b => [b.id, b])); } private async hydratePendingFromMeta(): Promise { @@ -283,6 +290,7 @@ export class LocalDatabase { | { key: string; value: User; timestamp: number } | { key: string; value: DelegationInfo; timestamp: number } | { key: string; value: unknown; timestamp: number } + | Bookmark ): void { if (!this.db) return; const tx = this.db.transaction(storeName, 'readwrite'); @@ -486,6 +494,96 @@ export class LocalDatabase { const store = tx.objectStore(STORE.UI_STATE); store.delete(key); } + + // ===== Bookmark Storage ===== + + /** + * Add a bookmark + */ + public async addBookmark(bookmark: Bookmark): Promise { + this.cache.bookmarks[bookmark.id] = bookmark; + this.put(STORE.BOOKMARKS, bookmark); + } + + /** + * Remove a bookmark + */ + public async removeBookmark(bookmarkId: string): Promise { + delete this.cache.bookmarks[bookmarkId]; + if (!this.db) return; + + const tx = this.db.transaction(STORE.BOOKMARKS, 'readwrite'); + const store = tx.objectStore(STORE.BOOKMARKS); + store.delete(bookmarkId); + } + + /** + * Get all bookmarks for a user + */ + public async getUserBookmarks(userId: string): Promise { + if (!this.db) return []; + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(STORE.BOOKMARKS, 'readonly'); + const store = tx.objectStore(STORE.BOOKMARKS); + const index = store.index('by_userId'); + const request = index.getAll(userId); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result as Bookmark[]); + }); + } + + /** + * Get bookmarks by type for a user + */ + public async getUserBookmarksByType( + userId: string, + type: 'post' | 'comment' + ): Promise { + if (!this.db) return []; + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(STORE.BOOKMARKS, 'readonly'); + const store = tx.objectStore(STORE.BOOKMARKS); + const index = store.index('by_userId'); + const request = index.getAll(userId); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const bookmarks = request.result as Bookmark[]; + const filtered = bookmarks.filter(b => b.type === type); + resolve(filtered); + }; + }); + } + + /** + * Check if an item is bookmarked by a user + */ + public isBookmarked( + userId: string, + type: 'post' | 'comment', + targetId: string + ): boolean { + const bookmarkId = `${type}:${targetId}`; + const bookmark = this.cache.bookmarks[bookmarkId]; + return bookmark?.userId === userId; + } + + /** + * Get bookmark by ID + */ + public getBookmark(bookmarkId: string): Bookmark | undefined { + return this.cache.bookmarks[bookmarkId]; + } + + /** + * Get all bookmarks from cache + */ + public getAllBookmarks(): Bookmark[] { + return Object.values(this.cache.bookmarks); + } } export const localDatabase = new LocalDatabase(); diff --git a/src/lib/database/schema.ts b/src/lib/database/schema.ts index aa4cfe9..e133610 100644 --- a/src/lib/database/schema.ts +++ b/src/lib/database/schema.ts @@ -1,5 +1,5 @@ export const DB_NAME = 'opchan-local'; -export const DB_VERSION = 2; +export const DB_VERSION = 3; export const STORE = { CELLS: 'cells', @@ -12,6 +12,7 @@ export const STORE = { DELEGATION: 'delegation', UI_STATE: 'uiState', META: 'meta', + BOOKMARKS: 'bookmarks', } as const; export type StoreName = (typeof STORE)[keyof typeof STORE]; @@ -72,6 +73,15 @@ export function openLocalDB(): Promise { // Misc metadata like lastSync timestamps db.createObjectStore(STORE.META, { keyPath: 'key' }); } + if (!db.objectStoreNames.contains(STORE.BOOKMARKS)) { + const store = db.createObjectStore(STORE.BOOKMARKS, { keyPath: 'id' }); + // Index to fetch bookmarks by user + store.createIndex('by_userId', 'userId', { unique: false }); + // Index to fetch bookmarks by type + store.createIndex('by_type', 'type', { unique: false }); + // Index to fetch bookmarks by targetId + store.createIndex('by_targetId', 'targetId', { unique: false }); + } }; }); } diff --git a/src/lib/services/BookmarkService.ts b/src/lib/services/BookmarkService.ts new file mode 100644 index 0000000..486efaf --- /dev/null +++ b/src/lib/services/BookmarkService.ts @@ -0,0 +1,172 @@ +import { Bookmark, BookmarkType, Post, Comment } from '@/types/forum'; +import { localDatabase } from '@/lib/database/LocalDatabase'; + +/** + * Service for managing bookmarks + * Handles all bookmark-related operations including CRUD operations + * and metadata extraction for display purposes + */ +export class BookmarkService { + /** + * Create a bookmark for a post + */ + public static async bookmarkPost( + post: Post, + userId: string, + cellId?: string + ): Promise { + const bookmark: Bookmark = { + id: `post:${post.id}`, + type: BookmarkType.POST, + targetId: post.id, + userId, + createdAt: Date.now(), + title: post.title, + author: post.author, + cellId: cellId || post.cellId, + }; + + await localDatabase.addBookmark(bookmark); + return bookmark; + } + + /** + * Create a bookmark for a comment + */ + public static async bookmarkComment( + comment: Comment, + userId: string, + postId?: string + ): Promise { + // Create a preview of the comment content for display + const preview = + comment.content.length > 100 + ? comment.content.substring(0, 100) + '...' + : comment.content; + + const bookmark: Bookmark = { + id: `comment:${comment.id}`, + type: BookmarkType.COMMENT, + targetId: comment.id, + userId, + createdAt: Date.now(), + title: preview, + author: comment.author, + postId: postId || comment.postId, + }; + + await localDatabase.addBookmark(bookmark); + return bookmark; + } + + /** + * Remove a bookmark + */ + public static async removeBookmark( + type: BookmarkType, + targetId: string + ): Promise { + const bookmarkId = `${type}:${targetId}`; + await localDatabase.removeBookmark(bookmarkId); + } + + /** + * Toggle bookmark status for a post + */ + public static async togglePostBookmark( + post: Post, + userId: string, + cellId?: string + ): Promise { + const isBookmarked = localDatabase.isBookmarked(userId, 'post', post.id); + + if (isBookmarked) { + await this.removeBookmark(BookmarkType.POST, post.id); + return false; + } else { + await this.bookmarkPost(post, userId, cellId); + return true; + } + } + + /** + * Toggle bookmark status for a comment + */ + public static async toggleCommentBookmark( + comment: Comment, + userId: string, + postId?: string + ): Promise { + const isBookmarked = localDatabase.isBookmarked( + userId, + 'comment', + comment.id + ); + + if (isBookmarked) { + await this.removeBookmark(BookmarkType.COMMENT, comment.id); + return false; + } else { + await this.bookmarkComment(comment, userId, postId); + return true; + } + } + + /** + * Check if a post is bookmarked by a user + */ + public static isPostBookmarked(userId: string, postId: string): boolean { + return localDatabase.isBookmarked(userId, 'post', postId); + } + + /** + * Check if a comment is bookmarked by a user + */ + public static isCommentBookmarked( + userId: string, + commentId: string + ): boolean { + return localDatabase.isBookmarked(userId, 'comment', commentId); + } + + /** + * Get all bookmarks for a user + */ + public static async getUserBookmarks(userId: string): Promise { + return localDatabase.getUserBookmarks(userId); + } + + /** + * Get bookmarks by type for a user + */ + public static async getUserBookmarksByType( + userId: string, + type: BookmarkType + ): Promise { + return localDatabase.getUserBookmarksByType(userId, type); + } + + /** + * Get bookmark by ID + */ + public static getBookmark(bookmarkId: string): Bookmark | undefined { + return localDatabase.getBookmark(bookmarkId); + } + + /** + * Get all bookmarks (for debugging/admin purposes) + */ + public static getAllBookmarks(): Bookmark[] { + return localDatabase.getAllBookmarks(); + } + + /** + * Clear all bookmarks for a user (useful for account cleanup) + */ + public static async clearUserBookmarks(userId: string): Promise { + const bookmarks = await this.getUserBookmarks(userId); + await Promise.all( + bookmarks.map(bookmark => localDatabase.removeBookmark(bookmark.id)) + ); + } +} diff --git a/src/pages/BookmarksPage.tsx b/src/pages/BookmarksPage.tsx new file mode 100644 index 0000000..dcebf6d --- /dev/null +++ b/src/pages/BookmarksPage.tsx @@ -0,0 +1,260 @@ +import { useState } from 'react'; +import Header from '@/components/Header'; +import { BookmarkList } from '@/components/ui/bookmark-card'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { useBookmarks } from '@/hooks'; +import { Bookmark, BookmarkType } from '@/types/forum'; +import { + Trash2, + Bookmark as BookmarkIcon, + FileText, + MessageSquare, +} from 'lucide-react'; +import { useAuth } from '@/contexts/useAuth'; + +const BookmarksPage = () => { + const { currentUser } = useAuth(); + const { + bookmarks, + loading, + error, + removeBookmark, + getBookmarksByType, + clearAllBookmarks, + } = useBookmarks(); + + const [activeTab, setActiveTab] = useState<'all' | 'posts' | 'comments'>( + 'all' + ); + + // Redirect to login if not authenticated + if (!currentUser) { + return ( +
+
+
+
+

+ Authentication Required +

+

+ Please connect your wallet to view your bookmarks. +

+
+
+
+ ); + } + + const postBookmarks = getBookmarksByType(BookmarkType.POST); + const commentBookmarks = getBookmarksByType(BookmarkType.COMMENT); + + const getFilteredBookmarks = () => { + switch (activeTab) { + case 'posts': + return postBookmarks; + case 'comments': + return commentBookmarks; + default: + return bookmarks; + } + }; + + const handleNavigate = (bookmark: Bookmark) => { + if (bookmark.type === BookmarkType.POST) { + window.location.href = `/post/${bookmark.targetId}`; + } else if (bookmark.type === BookmarkType.COMMENT && bookmark.postId) { + window.location.href = `/post/${bookmark.postId}#comment-${bookmark.targetId}`; + } + }; + + const handleClearAll = async () => { + await clearAllBookmarks(); + }; + + if (loading) { + return ( +
+
+
+
+
+

Loading bookmarks...

+
+
+
+ ); + } + + if (error) { + return ( +
+
+
+
+

+ Error Loading Bookmarks +

+

{error}

+ +
+
+
+ ); + } + + return ( +
+
+ +
+ {/* Header Section */} +
+
+
+ +

+ My Bookmarks +

+
+ + {bookmarks.length > 0 && ( + + + + + + + Clear All Bookmarks + + Are you sure you want to remove all your bookmarks? This + action cannot be undone. + + + + Cancel + + Clear All + + + + + )} +
+ +

+ Your saved posts and comments. Bookmarks are stored locally and + won't be shared. +

+
+ + {/* Stats */} + {bookmarks.length > 0 && ( +
+ + + {postBookmarks.length} Posts + + + + {commentBookmarks.length} Comments + + + + {bookmarks.length} Total + +
+ )} + + {/* Tabs */} + + setActiveTab(value as 'all' | 'posts' | 'comments') + } + className="w-full" + > + + + + All ({bookmarks.length}) + + + + Posts ({postBookmarks.length}) + + + + Comments ({commentBookmarks.length}) + + + + + + + + + + + + + + + +
+ +
+

OpChan - A decentralized forum built on Waku & Bitcoin Ordinals

+
+
+ ); +}; + +export default BookmarksPage; diff --git a/src/pages/FeedPage.tsx b/src/pages/FeedPage.tsx index 74a22d0..b42f228 100644 --- a/src/pages/FeedPage.tsx +++ b/src/pages/FeedPage.tsx @@ -22,8 +22,12 @@ const FeedPage: React.FC = () => { const { refreshData } = useForumActions(); const [sortOption, setSortOption] = useState('relevance'); - const { filteredPosts, filteredCommentsByPost, isInitialLoading, isRefreshing } = - forumData; + const { + filteredPosts, + filteredCommentsByPost, + isInitialLoading, + isRefreshing, + } = forumData; // ✅ Use pre-computed filtered data const allPosts = useMemo(() => { diff --git a/src/types/forum.ts b/src/types/forum.ts index fe13636..fa0a5f2 100644 --- a/src/types/forum.ts +++ b/src/types/forum.ts @@ -126,3 +126,34 @@ export interface UserVerificationStatus { verificationStatus?: EVerificationStatus; }; } + +/** + * Bookmark types for posts and comments + */ +export enum BookmarkType { + POST = 'post', + COMMENT = 'comment', +} + +/** + * Bookmark data structure + */ +export interface Bookmark { + id: string; // Composite key: `${type}:${targetId}` + type: BookmarkType; + targetId: string; // Post ID or Comment ID + userId: string; // User's wallet address + createdAt: number; // Timestamp when bookmarked + // Optional metadata for display + title?: string; // Post title or comment preview + author?: string; // Author address + cellId?: string; // For posts, the cell they belong to + postId?: string; // For comments, the post they belong to +} + +/** + * Bookmark cache for in-memory storage + */ +export interface BookmarkCache { + [bookmarkId: string]: Bookmark; +}