"use client"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@prototype/components/ui/tooltip"; import { useEffect, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode, } from "react"; import { isAnnotationResolved, sortAnnotationsForDisplay, } from "../core/annotation-status"; import { getRepliesForParent } from "../core/comment-threads"; import type { CommentAnnotation } from "../core/types"; import { useCommentStoreOptional } from "../react/CommentProvider"; import { CommentThread } from "./CommentThread"; import { CommentAuthorAvatar } from "./comment-author-avatar"; import { usePrototypeToolTheme } from "@prototype/lib/prototypes/use-prototype-tool-theme"; import { getViewportBoundingBox } from "./comment-highlight"; import { PR_TARGET_HIGHLIGHT_BORDER, PR_TARGET_HIGHLIGHT_FILL, } from "@prototype/lib/pr-split/pr-split-highlight"; import cardStyles from "./comments-grid-card.module.scss"; import { cn } from "@prototype/lib/utils"; function reviewSidebarCardBorder( selected: boolean, hovered: boolean, ): string { if (selected) { return PR_TARGET_HIGHLIGHT_BORDER; } if (hovered) { return "var(--border-medium)"; } return "var(--border-solid)"; } const s = { emptyState: { display: "flex", flexDirection: "column" as const, alignItems: "center", justifyContent: "center", padding: "48px 24px", textAlign: "center" as const, color: "var(--text-tertiary)", fontSize: "14px", gap: "8px", }, emptyIcon: { width: "40px", height: "40px", opacity: 0.45, marginBottom: "4px", color: "var(--text-secondary)", }, grid: (layout: "grid" | "sidebar"): CSSProperties => ({ display: "grid", gridTemplateColumns: layout === "sidebar" ? "minmax(0, 1fr)" : "repeat(2, minmax(0, 1fr))", gap: layout === "sidebar" ? "12px" : "16px", padding: layout === "sidebar" ? "0" : "4px 2px", }), card: ( hovered: boolean, selected: boolean, resolved: boolean, layout: "grid" | "sidebar" = "grid", ): CSSProperties => ({ display: "flex", flexDirection: "column", borderRadius: layout === "sidebar" ? "0.5rem" : "6px", overflow: "hidden", border: `1px solid ${ selected ? PR_TARGET_HIGHLIGHT_BORDER : hovered ? "var(--border-medium)" : "var(--border-solid)" }`, background: "var(--bg-main)", cursor: "pointer", transition: "border-color 0.15s ease, background 0.15s ease, opacity 0.15s ease", position: "relative", opacity: resolved ? 0.72 : 1, }), screenshot: { position: "relative" as const, overflow: "hidden", background: "var(--bg-subtle)", borderBottom: "1px solid var(--border-solid)", }, screenshotImg: { display: "block", width: "100%", height: "auto", }, noScreenshot: { display: "flex", alignItems: "center", justifyContent: "center", aspectRatio: "16 / 9", color: "var(--text-tertiary)", fontSize: "13px", }, highlight: ( isMultiSelect: boolean, x: number, y: number, w: number, h: number, vw: number, vh: number, ): CSSProperties => ({ position: "absolute", left: `${(x / vw) * 100}%`, top: `${(y / vh) * 100}%`, width: `${(w / vw) * 100}%`, height: `${(h / vh) * 100}%`, boxSizing: "border-box", borderRadius: "4px", border: isMultiSelect ? "2px dashed rgba(34,197,94,0.8)" : `1.5px solid ${PR_TARGET_HIGHLIGHT_BORDER}`, background: isMultiSelect ? "rgba(34,197,94,0.12)" : PR_TARGET_HIGHLIGHT_FILL, pointerEvents: "none", }), content: { padding: "10px 12px 12px", display: "flex", flexDirection: "column" as const, gap: "6px", flex: 1, }, comment: (resolved: boolean): CSSProperties => ({ fontSize: "14px", lineHeight: 1.5, color: resolved ? "var(--text-secondary)" : "var(--text-primary)", margin: 0, display: "-webkit-box" as unknown as "block", WebkitBoxOrient: "vertical" as const, WebkitLineClamp: 3, overflow: "hidden", textDecoration: resolved ? "line-through" : "none", }), resolvedBadge: { alignSelf: "flex-start", fontSize: "11px", fontWeight: 500, lineHeight: 1, color: "var(--success)", background: "color-mix(in srgb, var(--success) 12%, transparent)", borderRadius: "4px", padding: "4px 6px", }, actions: (visible: boolean, resolved: boolean): CSSProperties => ({ position: "absolute", top: "8px", right: "8px", display: "flex", gap: "4px", opacity: visible || resolved ? 1 : 0, transition: "opacity 0.15s", pointerEvents: visible || resolved ? "auto" : "none", }), actionBtn: (variant?: "danger" | "resolve" | "resolved"): CSSProperties => ({ width: "28px", height: "28px", borderRadius: "calc(var(--radius) - 2px)", border: "1px solid var(--tool-chrome-border)", background: "var(--tool-chrome-bg)", color: variant === "danger" ? "var(--tool-chrome-pink)" : variant === "resolved" ? "#22c55e" : "var(--tool-chrome-text-muted)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", padding: 0, boxShadow: "var(--shadow-sm)", }), editInput: { fontSize: "14px", lineHeight: 1.5, color: "var(--text-primary)", border: "1px solid var(--border-medium)", borderRadius: "6px", padding: "6px 8px", outline: "none", resize: "none" as const, width: "100%", fontFamily: "inherit", boxSizing: "border-box" as const, background: "var(--bg-main)", }, }; function PencilIcon() { return ( ); } function TrashIcon() { return ( ); } function CopyIcon() { return ( ); } function CopiedIcon() { return ( ); } function copyText(text: string): void { if (navigator.clipboard) { navigator.clipboard.writeText(text).catch(() => copyTextFallback(text)); } else { copyTextFallback(text); } } function copyTextFallback(text: string): void { const ta = document.createElement("textarea"); ta.value = text; ta.style.cssText = "position:fixed;opacity:0;pointer-events:none"; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); } function buildCopyPrompt( annotation: CommentAnnotation, prototypeSlug?: string, allAnnotations?: readonly CommentAnnotation[], ): string { const lines: string[] = []; lines.push(`Fix the following UI feedback:`); if (prototypeSlug) { lines.push(`Prototype: ${prototypeSlug}`); lines.push(`Work in: src/prototypes/${prototypeSlug}/`); } lines.push(`\nComment: "${annotation.comment}"`); if (annotation.authorName?.trim()) { lines.push(`Author: ${annotation.authorName.trim()}`); } if (allAnnotations) { const replies = getRepliesForParent(allAnnotations, annotation.id); for (const reply of replies) { lines.push(`Reply: "${reply.comment}"`); if (reply.authorName?.trim()) { lines.push(`Reply author: ${reply.authorName.trim()}`); } } } if (annotation.element) lines.push(`Element: ${annotation.element}`); if (annotation.sourceFile) lines.push(`Source file: ${annotation.sourceFile}`); if (annotation.reactComponents) lines.push(`React components: ${annotation.reactComponents}`); if (annotation.elementPath) lines.push(`Element path: ${annotation.elementPath}`); if (annotation.cssClasses) lines.push(`CSS classes: ${annotation.cssClasses}`); if (annotation.nearbyText) lines.push(`Nearby text: ${annotation.nearbyText}`); return lines.join("\n"); } function CheckIcon() { return ( ); } function ResolveIcon() { return ( ); } const ACTION_TOOLTIP_DELAY_MS = 500; type CommentActionButtonProps = { label: string; onClick: (event: MouseEvent) => void; variant?: "danger" | "resolve" | "resolved"; compact?: boolean; children: ReactNode; }; function CommentActionButton({ label, onClick, variant, compact = false, children, }: CommentActionButtonProps) { return ( {label} ); } type CommentCardProps = { annotation: CommentAnnotation; onSelect?: (id: string) => void; linkBasePath?: string; onDelete: (id: string) => void; onUpdateComment: (id: string, comment: string) => void; onResolve?: (id: string) => void; showResolveActions?: boolean; showCopyPrompt?: boolean; showReplies?: boolean; selected?: boolean; prototypeSlug?: string; layout?: "grid" | "sidebar"; expandComment?: boolean; }; function CommentCard({ annotation, onSelect, linkBasePath, onDelete, onUpdateComment, onResolve, showResolveActions = true, showCopyPrompt = true, showReplies = true, selected = false, prototypeSlug, layout = "grid", expandComment = false, }: CommentCardProps) { const commentStore = useCommentStoreOptional(); const { useLightTheme } = usePrototypeToolTheme(); const allAnnotations = commentStore?.annotations; const cardRef = useRef(null); const [hovered, setHovered] = useState(false); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(annotation.comment); const [copied, setCopied] = useState(false); const isResolved = showResolveActions && isAnnotationResolved(annotation); const isCondensed = isResolved && !editing; const replyCount = showReplies && allAnnotations ? getRepliesForParent(allAnnotations, annotation.id).length : 0; const showActions = hovered && !editing; useEffect(() => { if (selected && cardRef.current) { cardRef.current.scrollIntoView({ block: "nearest", behavior: "smooth" }); } }, [selected]); const viewportBox = getViewportBoundingBox(annotation); const viewport = annotation.captureViewport; const isSidebarLayout = layout === "sidebar"; const showResolvedBadge = showResolveActions && isResolved && !isCondensed; function handleSave() { if (draft.trim()) onUpdateComment(annotation.id, draft.trim()); setEditing(false); } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSave(); } if (e.key === "Escape") { setDraft(annotation.comment); setEditing(false); } } function handleSelect() { if (linkBasePath) { window.open(`${linkBasePath}/${annotation.id}`, "_blank", "noopener,noreferrer"); return; } onSelect?.(annotation.id); } function renderActionButtons(compact: boolean) { return ( <> {editing ? ( ) : ( <> {showResolveActions && onResolve ? ( { e.stopPropagation(); onResolve(annotation.id); }} > ) : null} { e.stopPropagation(); setDraft(annotation.comment); setEditing(true); }} > {showCopyPrompt ? ( { e.stopPropagation(); copyText( buildCopyPrompt( annotation as CommentAnnotation, prototypeSlug, allAnnotations as CommentAnnotation[] | undefined, ), ); setCopied(true); setTimeout(() => setCopied(false), 1500); }} > {copied ? : } ) : null} )} { e.stopPropagation(); onDelete(annotation.id); }} > ); } const showActionBar = showActions || isCondensed; const actionButtons = (
e.stopPropagation()} > {renderActionButtons(isCondensed)}
); return (
setHovered(true)} onMouseLeave={() => setHovered(false)} onClick={() => !editing && handleSelect()} role="button" tabIndex={0} onKeyDown={(e) => { if (editing) return; if (e.key === "Enter") { handleSelect(); return; } if ( selected && (e.key === "ArrowUp" || e.key === "ArrowDown") ) { e.preventDefault(); } }} > {actionButtons}
{annotation.screenshot ? ( <> {annotation.comment} {viewportBox && viewport && (
)} ) : (
No screenshot
)}
{showResolvedBadge ? ( Resolved ) : null}
{editing ? (