"use client"; import { useState, useEffect, useMemo } from "react"; import type { BeadsComment } from "@/hooks/useBeadsComments"; import { HeartIcon } from "@/components/HeartIcon"; import { formatRelativeTime } from "@/lib/utils"; /** Convert a simple glob pattern (with * wildcards) to a regex for matching */ function globToRegex(pattern: string): RegExp { const escaped = pattern.replace(/[.+^()|[\]\\]/g, '\\$&'); const withWildcards = escaped.replace(/\*/g, '.*'); return new RegExp('^' + withWildcards + '$', 'i'); } interface AllCommentsPanelProps { isOpen: boolean; onClose: () => void; allComments: BeadsComment[]; onNodeNavigate: (nodeId: string) => void; isAuthenticated?: boolean; currentDid?: string; onLikeComment?: (comment: BeadsComment) => Promise; onDeleteComment?: (comment: BeadsComment) => Promise; onReplyComment?: (parentComment: BeadsComment, text: string) => Promise; onPostComment?: (nodeId: string, text: string) => Promise; prefixes?: string[]; } export default function AllCommentsPanel({ isOpen, onClose, allComments, onNodeNavigate, isAuthenticated, currentDid, onLikeComment, onDeleteComment, onReplyComment, onPostComment, prefixes, }: AllCommentsPanelProps) { // Reply state const [replyingToUri, setReplyingToUri] = useState(null); const [replyText, setReplyText] = useState(""); const [isSubmittingReply, setIsSubmittingReply] = useState(false); // Filter state const [filterInput, setFilterInput] = useState(""); const [debouncedFilter, setDebouncedFilter] = useState(""); // Helper to find a comment by URI (search recursively through replies) const findCommentByUri = ( comments: BeadsComment[], uri: string ): BeadsComment | null => { for (const comment of comments) { if (comment.uri === uri) return comment; const found = findCommentByUri(comment.replies, uri); if (found) return found; } return null; }; // Clear reply state if the comment being replied to is deleted useEffect(() => { if (replyingToUri) { const comment = findCommentByUri(allComments, replyingToUri); if (!comment) { setReplyingToUri(null); setReplyText(''); } } }, [allComments, replyingToUri]); // Debounce filter input useEffect(() => { const timer = setTimeout(() => setDebouncedFilter(filterInput), 300); return () => clearTimeout(timer); }, [filterInput]); const handleSubmitReply = async () => { if (!replyText.trim() || !replyingToUri || !onReplyComment) return; const parentComment = findCommentByUri(allComments, replyingToUri); if (!parentComment) return; setIsSubmittingReply(true); try { await onReplyComment(parentComment, replyText.trim()); setReplyingToUri(null); setReplyText(""); } catch (err) { console.error("Failed to post reply:", err); } finally { setIsSubmittingReply(false); } }; // Filter comments by glob pattern const filteredComments = useMemo(() => { if (!debouncedFilter.trim()) return allComments; const regex = globToRegex(debouncedFilter.trim()); return allComments.filter(c => regex.test(c.nodeId)); }, [allComments, debouncedFilter]); // Extract shared content to avoid duplication between desktop and mobile const filterBarContent = (
setFilterInput(e.target.value)} placeholder="Filter by target (e.g. beads-map-*)" className="w-full pl-7 pr-7 py-1.5 text-xs border border-zinc-200 dark:border-zinc-700 rounded-md bg-zinc-50 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-emerald-500 focus:border-emerald-500" /> {/* Search icon */} {/* Clear button */} {filterInput && ( )}
{/* Quick filter chips */} {prefixes && prefixes.length > 1 && !filterInput && (
{prefixes.map(p => ( ))}
)}
); const commentListContent = (
{filteredComments.length === 0 && debouncedFilter ? (

No comments matching “{debouncedFilter}”

) : filteredComments.length === 0 ? (

No comments yet

Right-click a node to leave a comment

) : ( <> {/* Only show root comments (without replyTo) โ€” replies are nested */} {filteredComments.filter(c => !c.replyTo).map((comment) => ( setReplyingToUri(c.uri)} replyingToUri={replyingToUri} replyText={replyText} onReplyTextChange={setReplyText} onSubmitReply={handleSubmitReply} onCancelReply={() => { setReplyingToUri(null); setReplyText(""); }} isSubmittingReply={isSubmittingReply} depth={0} prefixes={prefixes} /> ))} )}
); const composeContent = (
{isAuthenticated ? ( ) : (
Sign in to comment
)}
{debouncedFilter ? ( <>{filteredComments.length} of {allComments.length} comments matching “{debouncedFilter}” ) : ( <>{allComments.length} comment{allComments.length !== 1 ? 's' : ''} across all issues )}
); return ( <> {/* Desktop: right sidebar */} {/* Mobile: bottom drawer */}
{/* Header with title + close button */}

All Comments

{/* Filter bar */} {filterBarContent} {/* Scrollable content: same comment list as desktop */}
{commentListContent}
{/* Compose area (same as desktop) */} {composeContent}
); } // ============================================================================ // AllCommentCard โ€” individual comment in the all-comments feed // ============================================================================ function AllCommentCard({ comment, currentDid, isAuthenticated, onNodeNavigate, onLike, onDelete, onStartReply, replyingToUri, replyText, onReplyTextChange, onSubmitReply, onCancelReply, isSubmittingReply, depth, prefixes, }: { comment: BeadsComment; currentDid?: string; isAuthenticated?: boolean; onNodeNavigate: (nodeId: string) => void; onLike?: (comment: BeadsComment) => Promise; onDelete?: (comment: BeadsComment) => Promise; onStartReply: (comment: BeadsComment) => void; replyingToUri: string | null; replyText: string; onReplyTextChange: (text: string) => void; onSubmitReply: () => Promise; onCancelReply: () => void; isSubmittingReply: boolean; depth: number; prefixes?: string[]; }) { const [liking, setLiking] = useState(false); const [deleting, setDeleting] = useState(false); const isOwn = currentDid && currentDid === comment.did; const hasLiked = currentDid ? comment.likes.some((l) => l.did === currentDid) : false; const isReplyingToThis = replyingToUri === comment.uri; const handleLike = async () => { if (!onLike || liking) return; setLiking(true); try { await onLike(comment); } catch (err) { console.error("Failed to toggle like:", err); } finally { setLiking(false); } }; const handleDelete = async () => { if (!onDelete || deleting) return; setDeleting(true); try { await onDelete(comment); } catch (err) { console.error("Failed to delete comment:", err); } finally { setDeleting(false); } }; return (
0 ? "ml-4 pl-3 border-l border-zinc-100 dark:border-zinc-800" : ""}`}>
{/* Node target pill โ€” only show for root comments */} {depth === 0 && (() => { const isPrefixComment = prefixes?.includes(comment.nodeId); return isPrefixComment ? ( ๐Ÿ“ข {comment.nodeId} ) : ( ); })()} {/* Author + time */}
{comment.avatar ? ( ) : (
{(comment.handle || comment.did).charAt(0).toUpperCase()}
)}
{comment.displayName || comment.handle || comment.did.slice(0, 16) + "..."} {formatRelativeTime(comment.createdAt)}
{/* Comment text */}

{comment.text}

{/* Actions */}
{/* Like */} {/* Reply button */} {/* Delete โ€” own only */} {isOwn && onDelete && ( )}
{/* Inline reply form */} {isReplyingToThis && ( )} {/* Nested replies */} {comment.replies.length > 0 && (
{comment.replies.map((reply) => ( ))}
)}
); } // ============================================================================ // InlineReplyForm โ€” same pattern as NodeDetail // ============================================================================ function InlineReplyForm({ replyingTo, replyText, onTextChange, onSubmit, onCancel, isSubmitting, }: { replyingTo: BeadsComment; replyText: string; onTextChange: (text: string) => void; onSubmit: () => void; onCancel: () => void; isSubmitting: boolean; }) { return (
onTextChange(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) onSubmit(); }} placeholder="Write a reply..." disabled={isSubmitting} autoFocus className="flex-1 px-2 py-1 text-xs bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:border-emerald-400 disabled:opacity-50" />
); } // ============================================================================ // GeneralCommentCompose โ€” compose form at bottom of panel // ============================================================================ function GeneralCommentCompose({ prefixes, onSubmit, }: { prefixes: string[]; onSubmit?: (subjectId: string, text: string) => Promise; }) { const [text, setText] = useState(""); const [selectedPrefix, setSelectedPrefix] = useState(prefixes[0] || ""); const [sending, setSending] = useState(false); // Auto-select if only one prefix useEffect(() => { if (prefixes.length === 1) setSelectedPrefix(prefixes[0]); }, [prefixes]); const handleSubmit = async () => { if (!text.trim() || !selectedPrefix || !onSubmit) return; setSending(true); try { await onSubmit(`${selectedPrefix}-general`, text.trim()); setText(""); } catch (err) { console.error("Failed to post comment:", err); } finally { setSending(false); } }; return (
{/* Prefix selector โ€” hidden if only 1 prefix */} {prefixes.length > 1 && ( )}