"use client"; import { useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { GraphNode } from "@/lib/types"; import { HeartIcon } from "@/components/HeartIcon"; import { formatRelativeTime, buildDescriptionCopyText } from "@/lib/utils"; import { STATUS_LABELS, STATUS_COLORS, PRIORITY_LABELS, PRIORITY_COLORS, TYPE_ICONS, PREFIX_LABELS, PREFIX_COLORS, } from "@/lib/types"; import type { BeadsComment } from "@/hooks/useBeadsComments"; function renderCloseReason(reason: string, repoUrl?: string): React.ReactNode { const baseUrl = repoUrl || "https://github.com/GainForest/heartbeads"; const parts = reason.split(/\b([0-9a-f]{7,40})\b/); if (parts.length === 1) return reason; return parts.map((part, i) => { if (/^[0-9a-f]{7,40}$/.test(part)) { return ( {part} ); } return {part}; }); } interface NodeDetailProps { node: GraphNode | null; allNodes: GraphNode[]; onNodeNavigate: (nodeId: string) => void; comments?: BeadsComment[]; onPostComment?: (text: string) => Promise; onDeleteComment?: (comment: BeadsComment) => Promise; onLikeComment?: (comment: BeadsComment) => Promise; onReplyComment?: (parentComment: BeadsComment, text: string) => Promise; isAuthenticated?: boolean; currentDid?: string; repoUrls?: Record; onOpenSettings?: () => void; onProfileClick?: (handle: string) => void; onShowDescription?: () => void; } export default function NodeDetail({ node, allNodes, onNodeNavigate, comments, onPostComment, onDeleteComment, onLikeComment, onReplyComment, isAuthenticated, currentDid, repoUrls, onOpenSettings, onProfileClick, onShowDescription, }: NodeDetailProps) { // Reply state — managed here so it's shared across the comment tree const [replyingToUri, setReplyingToUri] = useState(null); const [replyText, setReplyText] = useState(""); const [isSubmittingReply, setIsSubmittingReply] = useState(false); const [descCopied, setDescCopied] = useState(false); const handleStartReply = (comment: BeadsComment) => { setReplyingToUri(comment.uri); setReplyText(""); }; const handleCancelReply = () => { setReplyingToUri(null); setReplyText(""); }; const handleSubmitReply = async () => { if (!replyText.trim() || !replyingToUri || !onReplyComment) return; setIsSubmittingReply(true); try { // Find the comment we're replying to const findComment = ( items: BeadsComment[] ): BeadsComment | undefined => { for (const c of items) { if (c.uri === replyingToUri) return c; const found = findComment(c.replies); if (found) return found; } return undefined; }; const parentComment = comments ? findComment(comments) : undefined; if (parentComment) { await onReplyComment(parentComment, replyText.trim()); } setReplyingToUri(null); setReplyText(""); } catch (err) { console.error("Failed to post reply:", err); } finally { setIsSubmittingReply(false); } }; if (!node) { return (

Click a node to see details

Hover to highlight connections

); } const typeIcon = TYPE_ICONS[node.issueType] || "\uD83D\uDCCB"; const statusColor = STATUS_COLORS[node.status] || STATUS_COLORS.open; const statusLabel = STATUS_LABELS[node.status] || node.status; const priorityLabel = PRIORITY_LABELS[node.priority] || `P${node.priority}`; const priorityColor = PRIORITY_COLORS[node.priority] || "#a1a1aa"; const prefixLabel = PREFIX_LABELS[node.prefix] || node.prefix; const prefixColor = PREFIX_COLORS[node.prefix] || "#a1a1aa"; const repoUrl = repoUrls?.[node.prefix]; // Find blocker and dependent nodes const blockerNodes = node.blockerIds .map((id) => allNodes.find((n) => n.id === id)) .filter(Boolean) as GraphNode[]; const dependentNodes = node.dependentIds .map((id) => allNodes.find((n) => n.id === id)) .filter(Boolean) as GraphNode[]; // Format date with time const formatDate = (dateStr: string) => { try { const d = new Date(dateStr); const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }); const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: false, }); return `${date} at ${time}`; } catch { return dateStr; } }; return (
{/* Header */}
{typeIcon}
{node.id}

{node.title}

{/* Badges */}
{/* Status badge */} {statusLabel} {/* Priority */} {priorityLabel} {/* Project prefix */} {repoUrl ? ( {prefixLabel} ) : ( {prefixLabel} )}
{/* Labels */} {node.labels.length > 0 && (
{node.labels.map((label) => ( {label} ))}
)} {/* Repository link */} {repoUrl && ( )} {/* Metrics grid */}
0 ? "#f59e0b" : undefined} /> 0 ? "#ef4444" : undefined} />
{/* Dates */}
Created {formatDate(node.createdAt)}
Updated {formatDate(node.updatedAt)}
{node.closedAt && (
Closed {formatDate(node.closedAt)}
)} {node.closeReason && (
Reason {renderCloseReason(node.closeReason, repoUrl)}
)} {node.createdBy && (
Created by
)} {node.owner && node.owner !== node.createdBy && (
Owner
)}
Assignee {node.assignee ? ( ) : ( No assignee )}
{node.estimatedMinutes != null && (
Estimate ⏱ {node.estimatedMinutes}m
)}
{/* Description */} {node.description && (

Description

{node.description}
)} {/* Acceptance Criteria */} {node.acceptanceCriteria && (

Acceptance Criteria

            {node.acceptanceCriteria}
          
)} {/* Blocks (issues this blocks) */} {blockerNodes.length > 0 && (

Blocks ({blockerNodes.length})

{blockerNodes.map((dep) => ( onNodeNavigate(dep.id)} /> ))}
)} {/* Blocked by */} {dependentNodes.length > 0 && (

Blocked by ({dependentNodes.length})

{dependentNodes.map((dep) => ( onNodeNavigate(dep.id)} /> ))}
)} {/* Comments */}

Comments{" "} {comments && comments.length > 0 && ( {comments.length} )}

{/* Comment list */} {comments && comments.length > 0 ? (
{comments.map((comment) => ( ))}
) : (

No comments yet

)} {/* Compose area */} {isAuthenticated && onPostComment ? ( ) : !isAuthenticated ? (

Sign in to leave a comment

) : null}
); } // ============================================================================ // InlineReplyForm — ported from Hyperscan ReviewSection // ============================================================================ 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" />
); } // ============================================================================ // Sub-components // ============================================================================ function MetricCard({ label, value, color, }: { label: string; value: number; color?: string; }) { return (
{value}
{label}
); } function DependencyLink({ node, onClick, }: { node: GraphNode; onClick: () => void; }) { const statusColor = STATUS_COLORS[node.status] || STATUS_COLORS.open; return ( ); } function CommentItem({ comment, currentDid, isAuthenticated, onDelete, onLike, onStartReply, replyingToUri, replyText, onReplyTextChange, onSubmitReply, onCancelReply, isSubmittingReply, depth, }: { comment: BeadsComment; currentDid?: string; isAuthenticated?: boolean; onDelete?: (comment: BeadsComment) => Promise; onLike?: (comment: BeadsComment) => Promise; onStartReply: (comment: BeadsComment) => void; replyingToUri: string | null; replyText: string; onReplyTextChange: (text: string) => void; onSubmitReply: () => void; onCancelReply: () => void; isSubmittingReply: boolean; depth: number; }) { const [deleting, setDeleting] = useState(false); const [liking, setLiking] = 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 handleDelete = async () => { if (!onDelete || deleting) return; setDeleting(true); try { await onDelete(comment); } catch (err) { console.error("Failed to delete comment:", err); } finally { setDeleting(false); } }; 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); } }; return (
0 ? "ml-4 pl-3 border-l border-zinc-100 dark:border-zinc-800" : ""}`}>
{/* Header: avatar + name + date */}
{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 row: like, reply, delete */}
{/* Like button */} {/* Reply button */} {/* Delete button — only for own comments */} {isOwn && onDelete && ( )}
{/* Inline reply form */} {isReplyingToThis && ( )} {/* Nested replies */} {comment.replies.length > 0 && (
{comment.replies.map((reply) => ( ))}
)}
); } function CommentCompose({ onSubmit, }: { onSubmit: (text: string) => Promise; }) { const [text, setText] = useState(""); const [sending, setSending] = useState(false); const handleSubmit = async () => { if (!text.trim() || sending) return; setSending(true); try { await onSubmit(text.trim()); setText(""); } catch (err) { console.error("Failed to post comment:", err); } finally { setSending(false); } }; return (