From c2d7193a270f6208dc2fe44f75173480225e0169 Mon Sep 17 00:00:00 2001 From: sunny <5758289@qq.com> Date: Tue, 12 May 2026 14:36:58 +0800 Subject: [PATCH] feat(frontend): add bounty discussion thread with nested replies --- frontend/src/api/comments.ts | 28 ++++ .../src/components/bounty/BountyDetail.tsx | 3 + .../components/bounty/BountyDiscussion.tsx | 127 ++++++++++++++++++ frontend/src/hooks/useComments.ts | 24 ++++ 4 files changed, 182 insertions(+) create mode 100644 frontend/src/api/comments.ts create mode 100644 frontend/src/components/bounty/BountyDiscussion.tsx create mode 100644 frontend/src/hooks/useComments.ts diff --git a/frontend/src/api/comments.ts b/frontend/src/api/comments.ts new file mode 100644 index 000000000..9adff5f85 --- /dev/null +++ b/frontend/src/api/comments.ts @@ -0,0 +1,28 @@ +import { apiClient } from '../services/apiClient'; + +export interface BountyComment { + id: string; + bounty_id: string; + parent_id?: string | null; + author: string; + message: string; + created_at: string; +} + +interface CommentsResponse { + items: BountyComment[]; +} + +export async function listBountyComments(bountyId: string): Promise { + return apiClient(`/api/bounties/${bountyId}/comments`); +} + +export async function createBountyComment( + bountyId: string, + payload: { message: string; parent_id?: string } +): Promise { + return apiClient(`/api/bounties/${bountyId}/comments`, { + method: 'POST', + body: payload, + }); +} diff --git a/frontend/src/components/bounty/BountyDetail.tsx b/frontend/src/components/bounty/BountyDetail.tsx index 65653fa8f..aedfe3036 100644 --- a/frontend/src/components/bounty/BountyDetail.tsx +++ b/frontend/src/components/bounty/BountyDetail.tsx @@ -6,6 +6,7 @@ import type { Bounty } from '../../types/bounty'; import { timeLeft, timeAgo, formatCurrency, LANG_COLORS } from '../../lib/utils'; import { useAuth } from '../../hooks/useAuth'; import { SubmissionForm } from './SubmissionForm'; +import { BountyDiscussion } from './BountyDiscussion'; import { fadeIn } from '../../lib/animations'; interface BountyDetailProps { @@ -92,6 +93,8 @@ export function BountyDetail({ bounty }: BountyDetailProps) {

+ + {/* Submission form */} {bounty.status === 'open' || bounty.status === 'funded' ? (
diff --git a/frontend/src/components/bounty/BountyDiscussion.tsx b/frontend/src/components/bounty/BountyDiscussion.tsx new file mode 100644 index 000000000..0ee468041 --- /dev/null +++ b/frontend/src/components/bounty/BountyDiscussion.tsx @@ -0,0 +1,127 @@ +import React, { useMemo, useState } from 'react'; +import { MessageSquare, Reply, Send } from 'lucide-react'; +import type { Bounty } from '../../types/bounty'; +import { timeAgo } from '../../lib/utils'; +import { useAuth } from '../../hooks/useAuth'; +import { useBountyComments, useCreateBountyComment } from '../../hooks/useComments'; + +const BLOCKED_TERMS = ['http://', 'https://', 'telegram', 'whatsapp', 'airdrop', 'wallet seed']; + +interface Node { + id: string; + parent_id?: string | null; + author: string; + message: string; + created_at: string; + children: Node[]; +} + +function buildTree(items: Node[]): Node[] { + const byId = new Map(); + const roots: Node[] = []; + items.forEach((i) => byId.set(i.id, { ...i, children: [] })); + byId.forEach((n) => { + if (n.parent_id && byId.has(n.parent_id)) byId.get(n.parent_id)!.children.push(n); + else roots.push(n); + }); + return roots; +} + +function CommentItem({ node, onReply }: { node: Node; onReply: (id: string) => void }) { + return ( +
+
+

{node.author}

+ {timeAgo(node.created_at)} +
+

{node.message}

+ + {node.children.length > 0 && ( +
+ {node.children.map((c) => ( + + ))} +
+ )} +
+ ); +} + +export function BountyDiscussion({ bounty }: { bounty: Bounty }) { + const { user, isAuthenticated } = useAuth(); + const [message, setMessage] = useState(''); + const [replyTo, setReplyTo] = useState(null); + const [localError, setLocalError] = useState(null); + const { data, isError } = useBountyComments(bounty.id); + const createComment = useCreateBountyComment(bounty.id); + + const items = data?.items ?? []; + const tree = useMemo(() => buildTree(items as Node[]), [items]); + + const onSubmit = async () => { + const text = message.trim(); + if (!text) return; + if (BLOCKED_TERMS.some((k) => text.toLowerCase().includes(k))) { + setLocalError('Message blocked by anti-spam filter. Remove links or contact handles.'); + return; + } + setLocalError(null); + await createComment.mutateAsync({ message: text, ...(replyTo ? { parent_id: replyTo } : {}) }); + setMessage(''); + setReplyTo(null); + }; + + return ( +
+
+ +

Discussion

+ live refresh: 5s +
+ + {isError &&

Comments API unavailable right now.

} + + {tree.length === 0 ? ( +

No comments yet — start the discussion.

+ ) : ( +
+ {tree.map((n) => ( + + ))} +
+ )} + + {isAuthenticated ? ( +
+ {replyTo && ( +

+ Replying in thread · +

+ )} +