mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-03 13:23:08 +00:00
feat: bookmarks
This commit is contained in:
parent
860b6a138f
commit
612d5595d7
@ -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>
|
||||
|
||||
179
src/components/CommentCard.tsx
Normal file
179
src/components/CommentCard.tsx
Normal 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;
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
88
src/components/ui/bookmark-button.tsx
Normal file
88
src/components/ui/bookmark-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
src/components/ui/bookmark-card.tsx
Normal file
173
src/components/ui/bookmark-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
256
src/hooks/core/useBookmarks.ts
Normal file
256
src/hooks/core/useBookmarks.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
172
src/lib/services/BookmarkService.ts
Normal file
172
src/lib/services/BookmarkService.ts
Normal 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
260
src/pages/BookmarksPage.tsx
Normal 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;
|
||||
@ -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(() => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user