feat: bookmarks

This commit is contained in:
Danish Arora 2025-09-05 17:24:29 +05:30
parent 860b6a138f
commit 612d5595d7
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
16 changed files with 1413 additions and 154 deletions

View File

@ -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 = () => (
<Route path="/cell/:cellId" element={<CellPage />} />
<Route path="/post/:postId" element={<PostPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</TooltipProvider>

View File

@ -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 (
<>
<span></span>
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
syncing
</span>
</>
);
};
const CommentCard: React.FC<CommentCardProps> = ({
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 (
<div className="border border-muted rounded-sm p-4 bg-card">
<div className="flex gap-4">
<div className="flex flex-col items-center">
<button
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${
getCommentVoteType() === 'upvote' ? 'text-cyber-accent' : ''
}`}
onClick={() => handleVoteComment(true)}
disabled={!canVote || isVoting}
title={
canVote ? 'Upvote comment' : 'Connect wallet and verify to vote'
}
>
<ArrowUp className="w-3 h-3" />
</button>
<span className="text-sm font-bold">{comment.voteScore}</span>
<button
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${
getCommentVoteType() === 'downvote' ? 'text-cyber-accent' : ''
}`}
onClick={() => handleVoteComment(false)}
disabled={!canVote || isVoting}
title={
canVote ? 'Downvote comment' : 'Connect wallet and verify to vote'
}
>
<ArrowDown className="w-3 h-3" />
</button>
{commentVotePending.isPending && (
<span className="mt-1 text-[10px] text-yellow-500">syncing</span>
)}
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<AuthorDisplay
address={comment.author}
className="text-xs"
showBadge={false}
/>
<span></span>
<Clock className="w-3 h-3" />
<span>
{formatDistanceToNow(new Date(comment.timestamp), {
addSuffix: true,
})}
</span>
<PendingBadge id={comment.id} />
</div>
<BookmarkButton
isBookmarked={isBookmarked}
loading={bookmarkLoading}
onClick={handleBookmark}
size="sm"
variant="ghost"
/>
</div>
<p className="text-sm break-words mb-2">{comment.content}</p>
<div className="flex items-center gap-2">
{canModerate && !comment.moderated && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-cyber-neutral hover:text-orange-500"
onClick={() => onModerateComment(comment.id)}
>
<Shield className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Moderate comment</p>
</TooltipContent>
</Tooltip>
)}
{cellId && canModerate && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-cyber-neutral hover:text-red-500"
onClick={() => onModerateUser(comment.author)}
>
<UserX className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Moderate user</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
</div>
</div>
);
};
export default CommentCard;

View File

@ -18,6 +18,7 @@ import {
Home,
Grid3X3,
User,
Bookmark,
} from 'lucide-react';
import {
Tooltip,
@ -197,17 +198,30 @@ const Header = () => {
<span>Cells</span>
</Link>
{isConnected && (
<Link
to="/profile"
className={`flex items-center space-x-1 px-3 py-1 rounded-sm text-sm transition-colors ${
location.pathname === '/profile'
? 'bg-cyber-accent/20 text-cyber-accent'
: 'text-cyber-neutral hover:text-cyber-accent hover:bg-cyber-muted/50'
}`}
>
<User className="w-4 h-4" />
<span>Profile</span>
</Link>
<>
<Link
to="/bookmarks"
className={`flex items-center space-x-1 px-3 py-1 rounded-sm text-sm transition-colors ${
location.pathname === '/bookmarks'
? 'bg-cyber-accent/20 text-cyber-accent'
: 'text-cyber-neutral hover:text-cyber-accent hover:bg-cyber-muted/50'
}`}
>
<Bookmark className="w-4 h-4" />
<span>Bookmarks</span>
</Link>
<Link
to="/profile"
className={`flex items-center space-x-1 px-3 py-1 rounded-sm text-sm transition-colors ${
location.pathname === '/profile'
? 'bg-cyber-accent/20 text-cyber-accent'
: 'text-cyber-neutral hover:text-cyber-accent hover:bg-cyber-muted/50'
}`}
>
<User className="w-4 h-4" />
<span>Profile</span>
</Link>
</>
)}
</nav>
</div>

View File

@ -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<PostCardProps> = ({ 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<PostCardProps> = ({ 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 (
<div className="bg-cyber-muted/20 border border-cyber-muted rounded-sm hover:border-cyber-accent/50 hover:bg-cyber-muted/30 transition-all duration-200 mb-2">
<div className="flex">
@ -147,22 +191,33 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
</p>
{/* Post actions */}
<div className="flex items-center space-x-4 text-xs text-cyber-neutral">
<div className="flex items-center space-x-1 hover:text-cyber-accent transition-colors">
<MessageSquare className="w-4 h-4" />
<span>{commentCount} comments</span>
<div className="flex items-center justify-between text-xs text-cyber-neutral">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-1 hover:text-cyber-accent transition-colors">
<MessageSquare className="w-4 h-4" />
<span>{commentCount} comments</span>
</div>
{isPending && (
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
syncing
</span>
)}
<button
onClick={handleShare}
className="hover:text-cyber-accent transition-colors flex items-center gap-1"
title="Copy link"
>
<Clipboard size={14} />
Share
</button>
</div>
{isPending && (
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
syncing
</span>
)}
<button className="hover:text-cyber-accent transition-colors">
Share
</button>
<button className="hover:text-cyber-accent transition-colors">
Save
</button>
<BookmarkButton
isBookmarked={isBookmarked}
loading={bookmarkLoading}
onClick={handleBookmark}
size="sm"
variant="ghost"
/>
</div>
</Link>
</div>

View File

@ -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 (
<>
<span></span>
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
syncing
</span>
</>
);
};
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 = () => {
)}
</div>
<h1 className="text-2xl font-bold mb-3">{post.title}</h1>
<div className="flex items-start justify-between mb-3">
<h1 className="text-2xl font-bold flex-1">{post.title}</h1>
<BookmarkButton
isBookmarked={isBookmarked}
loading={bookmarkLoading}
onClick={handleBookmark}
size="lg"
variant="ghost"
showText={true}
/>
</div>
<p className="text-sm whitespace-pre-wrap break-words">
{post.content}
</p>
@ -316,98 +311,15 @@ const PostDetail = () => {
</div>
) : (
visibleComments.map(comment => (
<div
<CommentCard
key={comment.id}
className="border border-muted rounded-sm p-4 bg-card"
>
<div className="flex gap-4">
<div className="flex flex-col items-center">
<button
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${
getCommentVoteType(comment.id) === 'upvote'
? 'text-cyber-accent'
: ''
}`}
onClick={() => handleVoteComment(comment.id, true)}
disabled={!canVote || isVoting}
>
<ArrowUp className="w-3 h-3" />
</button>
<span className="text-sm font-bold">{comment.voteScore}</span>
<button
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${
getCommentVoteType(comment.id) === 'downvote'
? 'text-cyber-accent'
: ''
}`}
onClick={() => handleVoteComment(comment.id, false)}
disabled={!canVote || isVoting}
>
<ArrowDown className="w-3 h-3" />
</button>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
<AuthorDisplay
address={comment.author}
className="text-xs"
showBadge={false}
/>
<span></span>
<Clock className="w-3 h-3" />
<span>
{formatDistanceToNow(new Date(comment.timestamp), {
addSuffix: true,
})}
</span>
<PendingBadge id={comment.id} />
</div>
<p className="text-sm break-words">{comment.content}</p>
{canModerate(cell?.id || '') && !comment.moderated && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-cyber-neutral hover:text-orange-500"
onClick={() => handleModerateComment(comment.id)}
>
<Shield className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Moderate comment</p>
</TooltipContent>
</Tooltip>
)}
{post.cell &&
canModerate(post.cell.id) &&
comment.author !== post.author && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-cyber-neutral hover:text-red-500"
onClick={() => handleModerateUser(comment.author)}
>
<UserX className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Moderate user</p>
</TooltipContent>
</Tooltip>
)}
{comment.moderated && (
<span className="ml-2 text-xs text-red-500">
[Moderated]
</span>
)}
</div>
</div>
</div>
comment={comment}
postId={postId}
cellId={cell?.id}
canModerate={canModerate(cell?.id || '')}
onModerateComment={handleModerateComment}
onModerateUser={handleModerateUser}
/>
))
)}
</div>

View File

@ -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 (
<Button
variant={variant}
size={size}
onClick={onClick}
disabled={loading}
className={cn(
sizeClasses[size],
'transition-colors duration-200',
isBookmarked
? 'text-cyber-accent hover:text-cyber-accent/80'
: 'text-cyber-neutral hover:text-cyber-accent',
className
)}
title={isBookmarked ? 'Remove bookmark' : 'Add bookmark'}
>
{loading ? (
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current" />
) : isBookmarked ? (
<BookmarkCheck size={iconSize[size]} className="fill-current" />
) : (
<Bookmark size={iconSize[size]} />
)}
{showText && (
<span className="ml-2 text-xs">
{isBookmarked ? 'Bookmarked' : 'Bookmark'}
</span>
)}
</Button>
);
}
interface BookmarkIndicatorProps {
isBookmarked: boolean;
className?: string;
}
export function BookmarkIndicator({
isBookmarked,
className,
}: BookmarkIndicatorProps) {
if (!isBookmarked) return null;
return (
<div
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-full bg-cyber-accent/10 text-cyber-accent text-xs',
className
)}
>
<BookmarkCheck size={12} className="fill-current" />
<span>Bookmarked</span>
</div>
);
}

View File

@ -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 (
<Card
className={cn(
'group cursor-pointer transition-all duration-200 hover:bg-cyber-muted/20 hover:border-cyber-accent/30',
className
)}
onClick={handleNavigate}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 min-w-0 flex-1">
{bookmark.type === BookmarkType.POST ? (
<FileText size={16} className="text-cyber-accent flex-shrink-0" />
) : (
<MessageSquare
size={16}
className="text-cyber-accent flex-shrink-0"
/>
)}
<Badge
variant="outline"
className="text-xs border-cyber-accent/30 text-cyber-accent"
>
{bookmark.type === BookmarkType.POST ? 'Post' : 'Comment'}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRemove}
className="opacity-0 group-hover:opacity-100 transition-opacity h-8 w-8 p-0 text-cyber-neutral hover:text-red-400"
title="Remove bookmark"
>
<Trash2 size={14} />
</Button>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-3">
{/* Title/Content Preview */}
<div>
<h3 className="font-medium text-cyber-light line-clamp-2">
{bookmark.title || 'Untitled'}
</h3>
</div>
{/* Author and Metadata */}
<div className="flex items-center justify-between text-sm text-cyber-neutral">
<div className="flex items-center gap-2">
<span>by</span>
<span className="font-medium text-cyber-light">
{authorInfo.displayName}
</span>
</div>
<div className="flex items-center gap-2">
<span>
{formatDistanceToNow(new Date(bookmark.createdAt), {
addSuffix: true,
})}
</span>
<ExternalLink size={12} className="opacity-50" />
</div>
</div>
{/* Additional Context */}
{bookmark.cellId && (
<div className="text-xs text-cyber-neutral">
Cell: {bookmark.cellId}
</div>
)}
</div>
</CardContent>
</Card>
);
}
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 (
<div
className={cn(
'flex flex-col items-center justify-center py-12 text-center',
className
)}
>
<BookmarkIcon size={48} className="text-cyber-neutral/50 mb-4" />
<h3 className="text-lg font-medium text-cyber-light mb-2">
{emptyMessage}
</h3>
<p className="text-cyber-neutral max-w-md">
Bookmark posts and comments you want to revisit later. Your bookmarks
are saved locally and won't be shared.
</p>
</div>
);
}
return (
<div className={cn('space-y-4', className)}>
{bookmarks.map(bookmark => (
<BookmarkCard
key={bookmark.id}
bookmark={bookmark}
onRemove={onRemove}
onNavigate={onNavigate}
/>
))}
</div>
);
}

View File

@ -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<Bookmark[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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,
};
}

View File

@ -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;

View File

@ -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 {

View File

@ -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<void> {
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<CellMessage>(STORE.CELLS),
this.getAllFromStore<PostMessage>(STORE.POSTS),
@ -220,6 +225,7 @@ export class LocalDatabase {
this.getAllFromStore<{ address: string } & UserIdentityCache[string]>(
STORE.USER_IDENTITIES
),
this.getAllFromStore<Bookmark>(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<void> {
@ -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<void> {
this.cache.bookmarks[bookmark.id] = bookmark;
this.put(STORE.BOOKMARKS, bookmark);
}
/**
* Remove a bookmark
*/
public async removeBookmark(bookmarkId: string): Promise<void> {
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<Bookmark[]> {
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<Bookmark[]> {
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();

View File

@ -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<IDBDatabase> {
// 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 });
}
};
});
}

View File

@ -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<Bookmark> {
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<Bookmark> {
// 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<void> {
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<boolean> {
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<boolean> {
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<Bookmark[]> {
return localDatabase.getUserBookmarks(userId);
}
/**
* Get bookmarks by type for a user
*/
public static async getUserBookmarksByType(
userId: string,
type: BookmarkType
): Promise<Bookmark[]> {
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<void> {
const bookmarks = await this.getUserBookmarks(userId);
await Promise.all(
bookmarks.map(bookmark => localDatabase.removeBookmark(bookmark.id))
);
}
}

260
src/pages/BookmarksPage.tsx Normal file
View File

@ -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 (
<div className="min-h-screen flex flex-col bg-cyber-dark text-white">
<Header />
<main className="flex-1 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-cyber-light mb-4">
Authentication Required
</h1>
<p className="text-cyber-neutral">
Please connect your wallet to view your bookmarks.
</p>
</div>
</main>
</div>
);
}
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 (
<div className="min-h-screen flex flex-col bg-cyber-dark text-white">
<Header />
<main className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-cyber-accent mx-auto mb-4" />
<p className="text-cyber-neutral">Loading bookmarks...</p>
</div>
</main>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex flex-col bg-cyber-dark text-white">
<Header />
<main className="flex-1 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-400 mb-4">
Error Loading Bookmarks
</h1>
<p className="text-cyber-neutral mb-4">{error}</p>
<Button onClick={() => window.location.reload()}>Try Again</Button>
</div>
</main>
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-cyber-dark text-white">
<Header />
<main className="flex-1 container mx-auto px-4 py-8 max-w-4xl">
{/* Header Section */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<BookmarkIcon className="text-cyber-accent" size={32} />
<h1 className="text-3xl font-bold text-cyber-light">
My Bookmarks
</h1>
</div>
{bookmarks.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-red-400 border-red-400/30 hover:bg-red-400/10"
>
<Trash2 size={16} className="mr-2" />
Clear All
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear All Bookmarks</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove all your bookmarks? This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleClearAll}
className="bg-red-600 hover:bg-red-700"
>
Clear All
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<p className="text-cyber-neutral">
Your saved posts and comments. Bookmarks are stored locally and
won't be shared.
</p>
</div>
{/* Stats */}
{bookmarks.length > 0 && (
<div className="flex gap-4 mb-6">
<Badge
variant="outline"
className="border-cyber-accent/30 text-cyber-accent"
>
<FileText size={14} className="mr-1" />
{postBookmarks.length} Posts
</Badge>
<Badge
variant="outline"
className="border-cyber-accent/30 text-cyber-accent"
>
<MessageSquare size={14} className="mr-1" />
{commentBookmarks.length} Comments
</Badge>
<Badge
variant="outline"
className="border-cyber-accent/30 text-cyber-accent"
>
<BookmarkIcon size={14} className="mr-1" />
{bookmarks.length} Total
</Badge>
</div>
)}
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={value =>
setActiveTab(value as 'all' | 'posts' | 'comments')
}
className="w-full"
>
<TabsList className="grid w-full grid-cols-3 mb-6">
<TabsTrigger value="all" className="flex items-center gap-2">
<BookmarkIcon size={16} />
All ({bookmarks.length})
</TabsTrigger>
<TabsTrigger value="posts" className="flex items-center gap-2">
<FileText size={16} />
Posts ({postBookmarks.length})
</TabsTrigger>
<TabsTrigger value="comments" className="flex items-center gap-2">
<MessageSquare size={16} />
Comments ({commentBookmarks.length})
</TabsTrigger>
</TabsList>
<TabsContent value="all">
<BookmarkList
bookmarks={getFilteredBookmarks()}
onRemove={removeBookmark}
onNavigate={handleNavigate}
emptyMessage="No bookmarks yet"
/>
</TabsContent>
<TabsContent value="posts">
<BookmarkList
bookmarks={getFilteredBookmarks()}
onRemove={removeBookmark}
onNavigate={handleNavigate}
emptyMessage="No bookmarked posts yet"
/>
</TabsContent>
<TabsContent value="comments">
<BookmarkList
bookmarks={getFilteredBookmarks()}
onRemove={removeBookmark}
onNavigate={handleNavigate}
emptyMessage="No bookmarked comments yet"
/>
</TabsContent>
</Tabs>
</main>
<footer className="border-t border-cyber-muted py-4 text-center text-xs text-cyber-neutral">
<p>OpChan - A decentralized forum built on Waku & Bitcoin Ordinals</p>
</footer>
</div>
);
};
export default BookmarksPage;

View File

@ -22,8 +22,12 @@ const FeedPage: React.FC = () => {
const { refreshData } = useForumActions();
const [sortOption, setSortOption] = useState<SortOption>('relevance');
const { filteredPosts, filteredCommentsByPost, isInitialLoading, isRefreshing } =
forumData;
const {
filteredPosts,
filteredCommentsByPost,
isInitialLoading,
isRefreshing,
} = forumData;
// ✅ Use pre-computed filtered data
const allPosts = useMemo(() => {

View File

@ -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;
}