{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "page-comments",
  "title": "Page Comments",
  "description": "Figma-style commenting overlay with text selection highlights, pin comments, keyboard navigation, and replies. Includes adapters for Vercel KV, Upstash, Supabase, and Redis.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/blocks/collaboration/page-comments/components/elements/page-comments.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { createPortal } from \"react-dom\";\n\nimport {\n  inMemoryAdapter,\n  type NewComment,\n  type PageCommentsAdapter,\n  type PageCommentsComment,\n} from \"./page-comments-adapters\";\n\ninterface SelectionPopup {\n  x: number;\n  y: number;\n  text: string;\n}\n\nexport interface PageCommentsUser {\n  name: string;\n  avatar?: string;\n  color?: string;\n}\n\nexport interface PageCommentsProps {\n  pageId: string;\n  adapter?: PageCommentsAdapter;\n  contentSelector?: string;\n  user: PageCommentsUser;\n  keyboard?: boolean;\n  highlightStyle?: \"notion\" | \"minimal\" | \"none\";\n  onComment?: (comment: PageCommentsComment) => void;\n  onResolve?: (commentId: string) => void;\n  pollInterval?: number;\n}\n\nconst COLORS = [\n  \"#E93D82\",\n  \"#3E63DD\",\n  \"#30A46C\",\n  \"#E5484D\",\n  \"#6E56CF\",\n  \"#F76B15\",\n  \"#12A594\",\n  \"#7C66DC\",\n];\n\nfunction getColor(name: string, override?: string) {\n  if (override) return override;\n  let hash = 0;\n  for (let i = 0; i < name.length; i++) {\n    hash = name.charCodeAt(i) + ((hash << 5) - hash);\n  }\n  return COLORS[Math.abs(hash) % COLORS.length];\n}\n\nfunction timeAgo(ts: number) {\n  const diff = Date.now() - ts;\n  const mins = Math.floor(diff / 60000);\n  if (mins < 1) return \"just now\";\n  if (mins < 60) return `${mins}m ago`;\n  const hrs = Math.floor(mins / 60);\n  if (hrs < 24) return `${hrs}h ago`;\n  return `${Math.floor(hrs / 24)}d ago`;\n}\n\nfunction makeCursor(fillColor: string) {\n  const encoded = encodeURIComponent(fillColor);\n  return `url(\"data:image/svg+xml,%3Csvg width='22' height='26' viewBox='0 0 396 434' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M49.7 29.8L346.2 199.7L202.3 244l-82.9 119.1L49.7 29.8Z' fill='${encoded}' stroke='white' stroke-width='20'/%3E%3C/svg%3E\") 3 1, crosshair`;\n}\n\nfunction highlightQuotes(\n  quotes: { text: string; active: boolean; commentId?: string }[],\n  contentSelector: string,\n) {\n  for (const el of document.querySelectorAll(\"mark[data-slot='highlight']\")) {\n    const parent = el.parentNode;\n    if (parent) {\n      parent.replaceChild(document.createTextNode(el.textContent || \"\"), el);\n      parent.normalize();\n    }\n  }\n  if (quotes.length === 0) return;\n\n  const sel = window.getSelection();\n  if (!sel) return;\n\n  for (const q of quotes) {\n    if (!q.text) continue;\n    sel.removeAllRanges();\n\n    const found = (\n      window as unknown as { find: (...args: unknown[]) => boolean }\n    ).find(q.text, false, false, true, false, true, false);\n    if (!found) continue;\n\n    const range = sel.getRangeAt(0);\n    const container = document.querySelector(contentSelector);\n    if (container && !container.contains(range.commonAncestorContainer))\n      continue;\n\n    const mark = document.createElement(\"mark\");\n    mark.setAttribute(\"data-slot\", \"highlight\");\n    if (q.commentId) mark.setAttribute(\"data-comment-id\", q.commentId);\n\n    mark.className = [\n      \"rounded-sm px-0 py-px text-inherit underline cursor-pointer\",\n      \"decoration-2 underline-offset-[3px]\",\n      \"transition-[background,text-decoration-color] duration-150 ease-in-out\",\n    ].join(\" \");\n\n    if (q.active) {\n      mark.setAttribute(\"data-active\", \"\");\n      mark.style.background =\n        \"var(--highlight-bg-active, oklch(0.85 0.15 85 / 0.25))\";\n      mark.style.textDecorationColor =\n        \"var(--highlight-underline-active, oklch(0.75 0.15 85 / 0.6))\";\n    } else {\n      mark.style.background = \"var(--highlight-bg, oklch(0.85 0.15 85 / 0.1))\";\n      mark.style.textDecorationColor =\n        \"var(--highlight-underline, oklch(0.75 0.15 85 / 0.25))\";\n    }\n\n    try {\n      range.surroundContents(mark);\n    } catch {\n      const fragment = range.extractContents();\n      mark.appendChild(fragment);\n      range.insertNode(mark);\n    }\n  }\n  sel.removeAllRanges();\n}\n\nexport function PageComments({\n  pageId,\n  adapter: adapterProp,\n  contentSelector = \".prose\",\n  user,\n  keyboard = true,\n  highlightStyle = \"notion\",\n  onComment,\n  onResolve,\n  pollInterval = 10000,\n}: PageCommentsProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [adapter] = useState(() => adapterProp ?? inMemoryAdapter());\n  const [comments, setComments] = useState<PageCommentsComment[]>([]);\n  const [placing, setPlacing] = useState(false);\n  const [pendingPos, setPendingPos] = useState<{ x: number; y: number } | null>(\n    null,\n  );\n  const [pendingText, setPendingText] = useState(\"\");\n  const [pendingQuote, setPendingQuote] = useState(\"\");\n  const [activeComment, setActiveComment] = useState<string | null>(null);\n  const [selectionPopup, setSelectionPopup] = useState<SelectionPopup | null>(\n    null,\n  );\n  const [replyingTo, setReplyingTo] = useState<string | null>(null);\n  const [replyText, setReplyText] = useState(\"\");\n  const [focusedIdx, setFocusedIdx] = useState(-1);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const replyInputRef = useRef<HTMLInputElement>(null);\n  const selectionPopupRef = useRef<HTMLDivElement>(null);\n  const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);\n\n  const color = getColor(user.name, user.color);\n  const avatar = user.avatar ?? user.name[0]?.toUpperCase() ?? \"?\";\n\n  const unresolvedComments = comments\n    .filter((c) => !c.resolved)\n    .sort((a, b) => a.y - b.y);\n  const resolvedComments = comments\n    .filter((c) => c.resolved)\n    .sort((a, b) => a.y - b.y);\n  const navComments = [...unresolvedComments, ...resolvedComments];\n\n  const getParentRect = useCallback(() => {\n    const el = portalTarget ?? containerRef.current?.parentElement;\n    return (\n      el?.getBoundingClientRect() ?? {\n        left: 0,\n        top: 0,\n        width: document.documentElement.clientWidth,\n        height: document.documentElement.clientHeight,\n      }\n    );\n  }, [portalTarget]);\n\n  const toRelativeCoords = useCallback(\n    (clientX: number, clientY: number) => {\n      const rect = getParentRect();\n      const el = portalTarget ?? containerRef.current?.parentElement;\n      const xPercent = ((clientX - rect.left) / rect.width) * 100;\n      const yPx = clientY - rect.top + (el?.scrollTop ?? 0);\n      return { x: xPercent, y: yPx };\n    },\n    [getParentRect, portalTarget],\n  );\n\n  const getSelectionRelative = useCallback((): SelectionPopup | null => {\n    const sel = window.getSelection();\n    if (!sel || sel.isCollapsed || !sel.toString().trim()) return null;\n    const range = sel.getRangeAt(0);\n    const rangeRect = range.getBoundingClientRect();\n    const rect = getParentRect();\n    return {\n      x: rangeRect.left + rangeRect.width / 2 - rect.left,\n      y: rangeRect.top - rect.top,\n      text: sel.toString().trim(),\n    };\n  }, [getParentRect]);\n\n  const fetchComments = useCallback(async () => {\n    const result = await adapter.getComments(pageId);\n    setComments(result);\n  }, [adapter, pageId]);\n\n  useEffect(() => {\n    fetchComments();\n    const interval = setInterval(fetchComments, pollInterval);\n    return () => clearInterval(interval);\n  }, [fetchComments, pollInterval]);\n\n  useEffect(() => {\n    if (pendingPos && inputRef.current) inputRef.current.focus();\n  }, [pendingPos]);\n\n  const stateRef = useRef({ focusedIdx, navComments, placing });\n  stateRef.current = { focusedIdx, navComments, placing };\n\n  useEffect(() => {\n    const handleClick = (e: MouseEvent) => {\n      const target = e.target as HTMLElement;\n      const highlightMark = target.closest(\"mark[data-comment-id]\");\n      if (highlightMark) {\n        const cid = highlightMark.getAttribute(\"data-comment-id\");\n        if (cid) {\n          setActiveComment(cid);\n          const nc = stateRef.current.navComments;\n          setFocusedIdx(nc.findIndex((c) => c.id === cid));\n          return;\n        }\n      }\n      if (\n        target.closest(\"[data-slot='pin']\") ||\n        target.closest(\"[data-slot='input']\")\n      )\n        return;\n      if (target.closest(\"[data-slot='toolbar']\")) return;\n      if (stateRef.current.focusedIdx !== -1) {\n        setActiveComment(null);\n        setFocusedIdx(-1);\n      }\n    };\n    document.addEventListener(\"click\", handleClick);\n    return () => document.removeEventListener(\"click\", handleClick);\n  }, []);\n\n  useEffect(() => {\n    if (highlightStyle === \"none\") return;\n    const quotes: { text: string; active: boolean; commentId?: string }[] = [];\n    for (const c of comments) {\n      if (c.quote && !c.resolved) {\n        quotes.push({\n          text: c.quote,\n          active: c.id === activeComment,\n          commentId: c.id,\n        });\n      }\n    }\n    if (pendingQuote) {\n      quotes.push({ text: pendingQuote, active: true });\n    }\n    highlightQuotes(quotes, contentSelector);\n    return () => highlightQuotes([], contentSelector);\n  }, [activeComment, comments, pendingQuote, contentSelector, highlightStyle]);\n\n  useEffect(() => {\n    if (replyingTo && replyInputRef.current) replyInputRef.current.focus();\n  }, [replyingTo]);\n\n  useEffect(() => {\n    const handleMouseUp = () => {\n      setTimeout(() => {\n        const info = getSelectionRelative();\n        if (info && !pendingPos) setSelectionPopup(info);\n        else setSelectionPopup(null);\n      }, 10);\n    };\n    const handleMouseDown = (e: MouseEvent) => {\n      if (selectionPopupRef.current?.contains(e.target as Node)) return;\n      setSelectionPopup(null);\n    };\n    document.addEventListener(\"mouseup\", handleMouseUp);\n    document.addEventListener(\"mousedown\", handleMouseDown);\n    return () => {\n      document.removeEventListener(\"mouseup\", handleMouseUp);\n      document.removeEventListener(\"mousedown\", handleMouseDown);\n    };\n  }, [pendingPos, getSelectionRelative]);\n\n  const scrollToComment = useCallback(\n    (yInContainer: number) => {\n      const rect = getParentRect();\n      const absY = rect.top + window.scrollY + yInContainer;\n      window.scrollTo({\n        top: absY - window.innerHeight / 3,\n        behavior: \"smooth\",\n      });\n    },\n    [getParentRect],\n  );\n\n  useEffect(() => {\n    if (!keyboard) return;\n    const handleKeyDown = (e: KeyboardEvent) => {\n      const target = e.target as HTMLElement;\n      if (target.tagName === \"INPUT\" || target.tagName === \"TEXTAREA\") return;\n      const { focusedIdx: fi, navComments: nc, placing: pl } = stateRef.current;\n\n      if (e.key === \"ArrowDown\" && nc.length > 0) {\n        e.preventDefault();\n        const next = fi < nc.length - 1 ? fi + 1 : 0;\n        setFocusedIdx(next);\n        setActiveComment(nc[next].id);\n        scrollToComment(nc[next].y);\n      } else if (e.key === \"ArrowUp\" && nc.length > 0) {\n        e.preventDefault();\n        const prev = fi > 0 ? fi - 1 : nc.length - 1;\n        setFocusedIdx(prev);\n        setActiveComment(nc[prev].id);\n        scrollToComment(nc[prev].y);\n      } else if (e.key === \"c\" || e.key === \"C\") {\n        e.preventDefault();\n        setPlacing(!pl);\n        setPendingPos(null);\n        setPendingQuote(\"\");\n        setActiveComment(null);\n        setSelectionPopup(null);\n      } else if (e.key === \"Escape\") {\n        setActiveComment(null);\n        setFocusedIdx(-1);\n        setPendingPos(null);\n        setPendingText(\"\");\n        setPendingQuote(\"\");\n        if (pl) setPlacing(false);\n      }\n    };\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => document.removeEventListener(\"keydown\", handleKeyDown);\n  }, [keyboard, scrollToComment]);\n\n  const navigateComments = (dir: \"up\" | \"down\") => {\n    if (navComments.length === 0) return;\n    let next: number;\n    if (dir === \"down\") {\n      next = focusedIdx < navComments.length - 1 ? focusedIdx + 1 : 0;\n    } else {\n      next = focusedIdx > 0 ? focusedIdx - 1 : navComments.length - 1;\n    }\n    setFocusedIdx(next);\n    setActiveComment(navComments[next].id);\n    scrollToComment(navComments[next].y);\n  };\n\n  const startCommentFromSelection = (selInfo: SelectionPopup) => {\n    const rect = getParentRect();\n    const el = portalTarget ?? containerRef.current?.parentElement;\n    const xPercent = (selInfo.x / rect.width) * 100;\n    const yPx = selInfo.y + (el?.scrollTop ?? 0);\n    setPendingPos({ x: xPercent, y: yPx });\n    setPendingQuote(selInfo.text);\n    setPendingText(\"\");\n    setSelectionPopup(null);\n    setActiveComment(null);\n    window.getSelection()?.removeAllRanges();\n    if (!placing) setPlacing(true);\n  };\n\n  const handleOverlayClick = (e: React.MouseEvent) => {\n    if (!placing) return;\n    if ((e.target as HTMLElement).closest(\"[data-slot='pin']\")) return;\n    if ((e.target as HTMLElement).closest(\"[data-slot='input']\")) return;\n\n    const sel = window.getSelection();\n    if (sel && !sel.isCollapsed && sel.toString().trim()) {\n      const info = getSelectionRelative();\n      if (info) {\n        startCommentFromSelection(info);\n        return;\n      }\n    }\n\n    const { x, y } = toRelativeCoords(e.clientX, e.clientY);\n    setPendingPos({ x, y });\n    setPendingQuote(\"\");\n    setPendingText(\"\");\n    setActiveComment(null);\n  };\n\n  const submitComment = async () => {\n    if (!pendingText.trim() || !pendingPos) return;\n    const newComment: NewComment = {\n      name: user.name,\n      text: pendingText.trim(),\n      quote: pendingQuote || undefined,\n      x: pendingPos.x,\n      y: pendingPos.y,\n    };\n    const comment = await adapter.addComment(pageId, newComment);\n    setComments((prev) => [...prev, comment]);\n    setPendingPos(null);\n    setPendingText(\"\");\n    setPendingQuote(\"\");\n    if (comment.quote) setActiveComment(comment.id);\n    onComment?.(comment);\n  };\n\n  const toggleResolve = async (commentId: string) => {\n    const updated = await adapter.updateComment(pageId, commentId, {\n      action: \"resolve\",\n    });\n    if (updated) {\n      setComments((prev) =>\n        prev.map((c) => (c.id === commentId ? updated : c)),\n      );\n      onResolve?.(commentId);\n    }\n  };\n\n  const submitReply = async (commentId: string) => {\n    if (!replyText.trim()) return;\n    const updated = await adapter.updateComment(pageId, commentId, {\n      action: \"reply\",\n      name: user.name,\n      text: replyText.trim(),\n    });\n    if (updated) {\n      setComments((prev) =>\n        prev.map((c) => (c.id === commentId ? updated : c)),\n      );\n    }\n    setReplyText(\"\");\n    setReplyingTo(null);\n  };\n\n  const deleteComment = async (commentId: string) => {\n    await adapter.deleteComment(pageId, commentId, user.name);\n    setComments((prev) => prev.filter((c) => c.id !== commentId));\n    setActiveComment(null);\n  };\n\n  useEffect(() => {\n    const el = containerRef.current;\n    if (!el) return;\n    const parent = el.parentElement;\n    if (parent) {\n      const pos = getComputedStyle(parent).position;\n      if (pos === \"static\") parent.style.position = \"relative\";\n      setPortalTarget(parent);\n    }\n  }, []);\n\n  const overlayContent = portalTarget && (\n    <>\n      {/* Comment overlay */}\n      {placing && (\n        <button\n          type=\"button\"\n          onClick={handleOverlayClick}\n          className=\"absolute inset-0 z-[9990] border-none bg-transparent p-0 m-0\"\n          style={{ cursor: makeCursor(color) }}\n        />\n      )}\n\n      {/* Text selection tooltip */}\n      {selectionPopup && !placing && !pendingPos && (\n        <div\n          ref={selectionPopupRef}\n          className=\"absolute z-[9997] animate-in fade-in zoom-in-95 duration-150\"\n          style={{\n            left: selectionPopup.x,\n            top: selectionPopup.y - 8,\n            transform: \"translate(-50%, -100%)\",\n          }}\n        >\n          <button\n            type=\"button\"\n            onClick={() => startCommentFromSelection(selectionPopup)}\n            className=\"flex items-center gap-1.5 whitespace-nowrap rounded-lg border bg-popover px-2.5 py-1.5 text-xs text-muted-foreground shadow-lg transition-colors hover:border-primary hover:text-foreground\"\n          >\n            <svg\n              width=\"12\"\n              height=\"12\"\n              viewBox=\"0 0 16 16\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.5\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              aria-hidden=\"true\"\n            >\n              <path d=\"M2 4h12M2 8h8M2 12h10\" />\n            </svg>\n            Comment on selection\n          </button>\n          <div className=\"absolute -bottom-1 left-1/2 -ml-1 size-2 rotate-45 border-b border-r bg-popover\" />\n        </div>\n      )}\n\n      {/* Comment pins */}\n      {comments.map((c) => {\n        const isActive = activeComment === c.id;\n        const cColor = getColor(c.name);\n        return (\n          <div\n            key={c.id}\n            data-slot=\"pin\"\n            className=\"absolute -translate-x-3 -translate-y-3\"\n            style={{\n              left: `${c.x}%`,\n              top: c.y,\n              zIndex: isActive ? 9996 : 9995,\n            }}\n          >\n            <button\n              type=\"button\"\n              onClick={(e) => {\n                e.stopPropagation();\n                const newActive = isActive ? null : c.id;\n                setActiveComment(newActive);\n                setFocusedIdx(\n                  newActive\n                    ? navComments.findIndex((nc) => nc.id === c.id)\n                    : -1,\n                );\n                setReplyingTo(null);\n                setReplyText(\"\");\n              }}\n              className={`flex size-6 items-center justify-center rounded-full border-2 text-[10px] font-semibold shadow-md transition-transform hover:scale-[1.2] ${c.resolved ? \"border-emerald-300 text-emerald-950 ring-1 ring-emerald-400/50\" : \"border-background text-white\"}`}\n              style={{\n                background: c.resolved ? \"#34d399\" : cColor,\n              }}\n            >\n              {c.resolved ? \"\\u2713\" : c.name[0].toUpperCase()}\n            </button>\n\n            {isActive && (\n              <div className=\"absolute top-[30px] left-0 min-w-[260px] max-w-[320px] overflow-hidden rounded-xl border bg-popover shadow-lg animate-in fade-in zoom-in-95 duration-150\">\n                <div className=\"flex items-center gap-1.5 px-3 pt-2.5 pb-2\">\n                  <div\n                    className=\"flex size-[18px] items-center justify-center rounded-full text-[9px] font-semibold text-white\"\n                    style={{ background: cColor }}\n                  >\n                    {c.name[0].toUpperCase()}\n                  </div>\n                  <span className=\"text-xs font-medium text-foreground\">\n                    {c.name}\n                  </span>\n                  <span className=\"ml-auto text-[11px] text-muted-foreground\">\n                    {timeAgo(c.timestamp)}\n                  </span>\n                </div>\n\n                <p className=\"m-0 px-3 pb-2 text-[13px] leading-relaxed text-muted-foreground\">\n                  {c.text}\n                </p>\n\n                {c.replies && c.replies.length > 0 && (\n                  <div className=\"border-t\">\n                    {c.replies.map((r) => (\n                      <div key={r.id} className=\"border-b px-3 py-2\">\n                        <div className=\"mb-0.5 flex items-center gap-1\">\n                          <div\n                            className=\"flex size-3.5 items-center justify-center rounded-full text-[7px] font-semibold text-white\"\n                            style={{ background: getColor(r.name) }}\n                          >\n                            {r.name[0].toUpperCase()}\n                          </div>\n                          <span className=\"text-[11px] font-medium text-foreground\">\n                            {r.name}\n                          </span>\n                          <span className=\"ml-auto text-[10px] text-muted-foreground\">\n                            {timeAgo(r.timestamp)}\n                          </span>\n                        </div>\n                        <p className=\"m-0 pl-[18px] text-xs leading-snug text-muted-foreground\">\n                          {r.text}\n                        </p>\n                      </div>\n                    ))}\n                  </div>\n                )}\n\n                {replyingTo === c.id && (\n                  <div className=\"border-t px-3 py-2\">\n                    <input\n                      ref={replyInputRef}\n                      type=\"text\"\n                      placeholder=\"Reply...\"\n                      value={replyText}\n                      onChange={(e) => setReplyText(e.target.value)}\n                      onKeyDown={(e) => {\n                        if (e.key === \"Enter\") submitReply(c.id);\n                        if (e.key === \"Escape\") {\n                          setReplyingTo(null);\n                          setReplyText(\"\");\n                        }\n                      }}\n                      className=\"w-full border-none bg-transparent p-0 text-xs text-foreground outline-none placeholder:text-muted-foreground\"\n                    />\n                  </div>\n                )}\n\n                <div className=\"flex border-t\">\n                  <button\n                    type=\"button\"\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      toggleResolve(c.id);\n                    }}\n                    className=\"flex flex-1 items-center justify-center gap-1 border-r py-[7px] text-[11px] text-muted-foreground transition-colors hover:text-green-500\"\n                    style={{ color: c.resolved ? \"#30A46C\" : undefined }}\n                  >\n                    <svg\n                      width=\"12\"\n                      height=\"12\"\n                      viewBox=\"0 0 16 16\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"2\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      aria-hidden=\"true\"\n                    >\n                      <path d=\"M3 8.5l3.5 3.5 6.5-8\" />\n                    </svg>\n                    {c.resolved ? \"Reopen\" : \"Resolve\"}\n                  </button>\n                  <button\n                    type=\"button\"\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      setReplyingTo(replyingTo === c.id ? null : c.id);\n                      setReplyText(\"\");\n                    }}\n                    className={`flex flex-1 items-center justify-center gap-1 py-[7px] text-[11px] text-muted-foreground transition-colors hover:text-foreground ${c.name === user.name ? \"border-r\" : \"\"}`}\n                  >\n                    <svg\n                      width=\"12\"\n                      height=\"12\"\n                      viewBox=\"0 0 16 16\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"1.5\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      aria-hidden=\"true\"\n                    >\n                      <path d=\"M14 10c0 .55-.2 1.05-.59 1.41-.38.37-.88.59-1.41.59H5l-3 3V4c0-.55.2-1.05.59-1.41C2.97 2.2 3.45 2 4 2h8c.55 0 1.05.2 1.41.59.37.38.59.88.59 1.41v6z\" />\n                    </svg>\n                    Reply\n                  </button>\n                  {c.name === user.name && (\n                    <button\n                      type=\"button\"\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        deleteComment(c.id);\n                      }}\n                      className=\"flex flex-1 items-center justify-center gap-1 py-[7px] text-[11px] text-muted-foreground transition-colors hover:text-destructive\"\n                    >\n                      <svg\n                        width=\"12\"\n                        height=\"12\"\n                        viewBox=\"0 0 16 16\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"1.5\"\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                        aria-hidden=\"true\"\n                      >\n                        <path d=\"M2 4h12M5.33 4V2.67a1.33 1.33 0 011.34-1.34h2.66a1.33 1.33 0 011.34 1.34V4M12.67 4v9.33a1.33 1.33 0 01-1.34 1.34H4.67a1.33 1.33 0 01-1.34-1.34V4\" />\n                      </svg>\n                      Delete\n                    </button>\n                  )}\n                </div>\n              </div>\n            )}\n          </div>\n        );\n      })}\n\n      {/* Pending comment input */}\n      {pendingPos && (\n        <div\n          data-slot=\"input\"\n          className=\"absolute -translate-x-3 -translate-y-3 z-[9996]\"\n          style={{ left: `${pendingPos.x}%`, top: pendingPos.y }}\n        >\n          <div\n            className=\"flex size-6 items-center justify-center rounded-full border-2 border-background text-[10px] font-semibold text-white shadow-md\"\n            style={{ background: color }}\n          >\n            {avatar}\n          </div>\n          <div\n            className=\"absolute top-7 left-0 min-w-[240px] rounded-lg border bg-popover p-2 shadow-lg animate-in fade-in zoom-in-95 duration-150\"\n            style={{ borderColor: color }}\n          >\n            <input\n              ref={inputRef}\n              type=\"text\"\n              placeholder=\"Add a comment...\"\n              value={pendingText}\n              onChange={(e) => setPendingText(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\") submitComment();\n                if (e.key === \"Escape\") {\n                  setPendingPos(null);\n                  setPendingText(\"\");\n                  setPendingQuote(\"\");\n                }\n              }}\n              className=\"w-full border-none bg-transparent p-0 text-[13px] text-foreground outline-none placeholder:text-muted-foreground\"\n            />\n            <div className=\"mt-2 flex justify-end gap-1\">\n              <button\n                type=\"button\"\n                onClick={() => {\n                  setPendingPos(null);\n                  setPendingText(\"\");\n                  setPendingQuote(\"\");\n                }}\n                className=\"rounded px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground\"\n              >\n                Cancel\n              </button>\n              <button\n                type=\"button\"\n                onClick={submitComment}\n                disabled={!pendingText.trim()}\n                className=\"rounded px-2.5 py-0.5 text-xs font-medium text-white transition-opacity disabled:opacity-40\"\n                style={{ background: color }}\n              >\n                Post\n              </button>\n            </div>\n          </div>\n        </div>\n      )}\n    </>\n  );\n\n  return (\n    <div ref={containerRef} data-slot=\"page-comments\">\n      {/* Floating toolbar */}\n      <div\n        data-slot=\"toolbar\"\n        className=\"sticky bottom-6 z-[9998] mx-auto w-fit flex items-center gap-1.5 rounded-xl border bg-popover px-2.5 py-1.5 shadow-lg transition-colors\"\n        style={{ borderColor: placing ? color : undefined }}\n      >\n        <div\n          className=\"flex size-[22px] shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white\"\n          style={{ background: color }}\n        >\n          {avatar}\n        </div>\n        <span className=\"text-[13px] text-muted-foreground\">{user.name}</span>\n        <div className=\"mx-0.5 h-4 w-px bg-border\" />\n        <button\n          type=\"button\"\n          onClick={() => {\n            setPlacing(!placing);\n            setPendingPos(null);\n            setPendingQuote(\"\");\n            setActiveComment(null);\n            setSelectionPopup(null);\n          }}\n          className=\"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[13px] transition-colors\"\n          style={{\n            background: placing ? color : \"transparent\",\n            color: placing ? \"#fff\" : undefined,\n          }}\n        >\n          <svg\n            width=\"14\"\n            height=\"14\"\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.5\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            aria-hidden=\"true\"\n          >\n            <path d=\"M3 2l10 5.5L8 9l-1.5 5z\" />\n          </svg>\n          {placing ? \"Click anywhere...\" : \"Comment\"}\n        </button>\n        <div className=\"mx-0.5 h-4 w-px bg-border\" />\n        <span className=\"min-w-5 text-center text-xs text-muted-foreground\">\n          {comments.length}\n        </span>\n        {comments.length > 0 && (\n          <>\n            <button\n              type=\"button\"\n              onClick={() => navigateComments(\"up\")}\n              title=\"Previous comment (Arrow Up)\"\n              className=\"flex items-center justify-center rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground\"\n            >\n              <svg\n                width=\"14\"\n                height=\"14\"\n                viewBox=\"0 0 16 16\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.5\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                aria-hidden=\"true\"\n              >\n                <path d=\"M8 3v10M3 8l5-5 5 5\" />\n              </svg>\n            </button>\n            <button\n              type=\"button\"\n              onClick={() => navigateComments(\"down\")}\n              title=\"Next comment (Arrow Down)\"\n              className=\"flex items-center justify-center rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground\"\n            >\n              <svg\n                width=\"14\"\n                height=\"14\"\n                viewBox=\"0 0 16 16\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.5\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                aria-hidden=\"true\"\n              >\n                <path d=\"M8 13V3M3 8l5 5 5-5\" />\n              </svg>\n            </button>\n          </>\n        )}\n      </div>\n\n      {portalTarget && createPortal(overlayContent, portalTarget)}\n\n      <style>{`\n\t\t\t\tmark[data-slot=\"highlight\"]:not([data-active]):hover {\n\t\t\t\t\tbackground: var(--highlight-bg-hover, oklch(0.85 0.15 85 / 0.18)) !important;\n\t\t\t\t\ttext-decoration-color: var(--highlight-underline-hover, oklch(0.75 0.15 85 / 0.45)) !important;\n\t\t\t\t}\n\t\t\t\tmark[data-slot=\"highlight\"][data-active]:hover {\n\t\t\t\t\tbackground: var(--highlight-bg-active-hover, oklch(0.85 0.15 85 / 0.32)) !important;\n\t\t\t\t\ttext-decoration-color: var(--highlight-underline-active-hover, oklch(0.75 0.15 85 / 0.7)) !important;\n\t\t\t\t}\n\t\t\t`}</style>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/default/blocks/collaboration/page-comments/components/elements/page-comments-adapters.ts",
      "content": "\"use client\";\n\nexport interface PageCommentsReply {\n  id: string;\n  name: string;\n  text: string;\n  timestamp: number;\n}\n\nexport interface PageCommentsComment {\n  id: string;\n  name: string;\n  text: string;\n  quote?: string;\n  x: number;\n  y: number;\n  timestamp: number;\n  resolved: boolean;\n  replies?: PageCommentsReply[];\n}\n\nexport interface NewComment {\n  name: string;\n  text: string;\n  quote?: string;\n  x: number;\n  y: number;\n}\n\nexport interface PageCommentsAdapter {\n  getComments(pageId: string): Promise<PageCommentsComment[]>;\n  addComment(pageId: string, comment: NewComment): Promise<PageCommentsComment>;\n  updateComment(\n    pageId: string,\n    commentId: string,\n    data:\n      | { action: \"resolve\" }\n      | { action: \"reply\"; name: string; text: string },\n  ): Promise<PageCommentsComment | null>;\n  deleteComment(pageId: string, commentId: string, name: string): Promise<void>;\n}\n\nexport function inMemoryAdapter(): PageCommentsAdapter {\n  const store = new Map<string, PageCommentsComment[]>();\n\n  return {\n    async getComments(pageId) {\n      return store.get(pageId) ?? [];\n    },\n    async addComment(pageId, data) {\n      const comments = store.get(pageId) ?? [];\n      const comment: PageCommentsComment = {\n        id: Math.random().toString(36).slice(2, 10),\n        name: data.name,\n        text: data.text,\n        ...(data.quote ? { quote: data.quote } : {}),\n        x: data.x,\n        y: data.y,\n        timestamp: Date.now(),\n        resolved: false,\n      };\n      comments.push(comment);\n      store.set(pageId, comments);\n      return comment;\n    },\n    async updateComment(pageId, commentId, data) {\n      const comments = store.get(pageId) ?? [];\n      const idx = comments.findIndex((c) => c.id === commentId);\n      if (idx === -1) return null;\n\n      if (data.action === \"resolve\") {\n        comments[idx].resolved = !comments[idx].resolved;\n      } else if (data.action === \"reply\") {\n        if (!comments[idx].replies) comments[idx].replies = [];\n        comments[idx].replies?.push({\n          id: Math.random().toString(36).slice(2, 10),\n          name: data.name,\n          text: data.text,\n          timestamp: Date.now(),\n        });\n      }\n      store.set(pageId, comments);\n      return comments[idx];\n    },\n    async deleteComment(pageId, commentId) {\n      const comments = store.get(pageId) ?? [];\n      store.set(\n        pageId,\n        comments.filter((c) => c.id !== commentId),\n      );\n    },\n  };\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/default/blocks/collaboration/page-comments/components/elements/page-comments-kv.ts",
      "content": "import type {\n  PageCommentsAdapter,\n  PageCommentsComment,\n} from \"./page-comments-adapters\";\n\ninterface KvAdapterOptions {\n  url: string;\n  token: string;\n  prefix?: string;\n}\n\nexport function kvAdapter({\n  url,\n  token,\n  prefix = \"page-comments\",\n}: KvAdapterOptions): PageCommentsAdapter {\n  async function kvFetch(method: string, args: unknown[]) {\n    const res = await fetch(`${url}`, {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${token}`,\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify([method, ...args]),\n    });\n    const data = await res.json();\n    return data.result;\n  }\n\n  async function getAll(pageId: string): Promise<PageCommentsComment[]> {\n    const result = await kvFetch(\"get\", [`${prefix}:${pageId}`]);\n    if (!result) return [];\n    return typeof result === \"string\" ? JSON.parse(result) : result;\n  }\n\n  async function setAll(pageId: string, comments: PageCommentsComment[]) {\n    await kvFetch(\"set\", [`${prefix}:${pageId}`, JSON.stringify(comments)]);\n  }\n\n  return {\n    async getComments(pageId) {\n      return getAll(pageId);\n    },\n    async addComment(pageId, data) {\n      const comments = await getAll(pageId);\n      const comment: PageCommentsComment = {\n        id: crypto.randomUUID().slice(0, 8),\n        name: data.name,\n        text: data.text,\n        ...(data.quote ? { quote: data.quote } : {}),\n        x: data.x,\n        y: data.y,\n        timestamp: Date.now(),\n        resolved: false,\n      };\n      comments.push(comment);\n      await setAll(pageId, comments);\n      return comment;\n    },\n    async updateComment(pageId, commentId, data) {\n      const comments = await getAll(pageId);\n      const idx = comments.findIndex((c) => c.id === commentId);\n      if (idx === -1) return null;\n\n      if (data.action === \"resolve\") {\n        comments[idx].resolved = !comments[idx].resolved;\n      } else if (data.action === \"reply\") {\n        if (!comments[idx].replies) comments[idx].replies = [];\n        comments[idx].replies?.push({\n          id: crypto.randomUUID().slice(0, 8),\n          name: data.name,\n          text: data.text,\n          timestamp: Date.now(),\n        });\n      }\n      await setAll(pageId, comments);\n      return comments[idx];\n    },\n    async deleteComment(pageId, commentId) {\n      const comments = await getAll(pageId);\n      await setAll(\n        pageId,\n        comments.filter((c) => c.id !== commentId),\n      );\n    },\n  };\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/default/blocks/collaboration/page-comments/components/elements/page-comments-upstash.ts",
      "content": "import type {\n  PageCommentsAdapter,\n  PageCommentsComment,\n} from \"./page-comments-adapters\";\n\ninterface UpstashAdapterOptions {\n  url: string;\n  token: string;\n  prefix?: string;\n}\n\nexport function upstashAdapter({\n  url,\n  token,\n  prefix = \"page-comments\",\n}: UpstashAdapterOptions): PageCommentsAdapter {\n  async function redis(command: string, ...args: string[]) {\n    const res = await fetch(`${url}/${command}/${args.join(\"/\")}`, {\n      headers: { Authorization: `Bearer ${token}` },\n    });\n    const data = await res.json();\n    return data.result;\n  }\n\n  async function getAll(pageId: string): Promise<PageCommentsComment[]> {\n    const result = await redis(\"get\", `${prefix}:${pageId}`);\n    if (!result) return [];\n    return typeof result === \"string\" ? JSON.parse(result) : result;\n  }\n\n  async function setAll(pageId: string, comments: PageCommentsComment[]) {\n    await redis(\"set\", `${prefix}:${pageId}`, JSON.stringify(comments));\n  }\n\n  return {\n    async getComments(pageId) {\n      return getAll(pageId);\n    },\n    async addComment(pageId, data) {\n      const comments = await getAll(pageId);\n      const comment: PageCommentsComment = {\n        id: crypto.randomUUID().slice(0, 8),\n        name: data.name,\n        text: data.text,\n        ...(data.quote ? { quote: data.quote } : {}),\n        x: data.x,\n        y: data.y,\n        timestamp: Date.now(),\n        resolved: false,\n      };\n      comments.push(comment);\n      await setAll(pageId, comments);\n      return comment;\n    },\n    async updateComment(pageId, commentId, data) {\n      const comments = await getAll(pageId);\n      const idx = comments.findIndex((c) => c.id === commentId);\n      if (idx === -1) return null;\n\n      if (data.action === \"resolve\") {\n        comments[idx].resolved = !comments[idx].resolved;\n      } else if (data.action === \"reply\") {\n        if (!comments[idx].replies) comments[idx].replies = [];\n        comments[idx].replies?.push({\n          id: crypto.randomUUID().slice(0, 8),\n          name: data.name,\n          text: data.text,\n          timestamp: Date.now(),\n        });\n      }\n      await setAll(pageId, comments);\n      return comments[idx];\n    },\n    async deleteComment(pageId, commentId) {\n      const comments = await getAll(pageId);\n      await setAll(\n        pageId,\n        comments.filter((c) => c.id !== commentId),\n      );\n    },\n  };\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/default/blocks/collaboration/page-comments/components/elements/page-comments-supabase.ts",
      "content": "import type {\n  PageCommentsAdapter,\n  PageCommentsComment,\n} from \"./page-comments-adapters\";\n\ninterface SupabaseAdapterOptions {\n  client: {\n    // biome-ignore lint/suspicious/noExplicitAny: Supabase client returns dynamic query builders\n    from: (table: string) => any;\n  };\n  table?: string;\n}\n\nexport function supabaseAdapter({\n  client,\n  table = \"page_comments\",\n}: SupabaseAdapterOptions): PageCommentsAdapter {\n  return {\n    async getComments(pageId) {\n      const { data } = await client\n        .from(table)\n        .select(\"data\")\n        .eq(\"page_id\", pageId)\n        .single();\n      return data?.data ?? [];\n    },\n    async addComment(pageId, newComment) {\n      const comments = await this.getComments(pageId);\n      const comment: PageCommentsComment = {\n        id: crypto.randomUUID().slice(0, 8),\n        name: newComment.name,\n        text: newComment.text,\n        ...(newComment.quote ? { quote: newComment.quote } : {}),\n        x: newComment.x,\n        y: newComment.y,\n        timestamp: Date.now(),\n        resolved: false,\n      };\n      const updated = [...comments, comment];\n      await client\n        .from(table)\n        .upsert({ page_id: pageId, data: updated }, { onConflict: \"page_id\" });\n      return comment;\n    },\n    async updateComment(pageId, commentId, data) {\n      const comments = await this.getComments(pageId);\n      const idx = comments.findIndex(\n        (c: PageCommentsComment) => c.id === commentId,\n      );\n      if (idx === -1) return null;\n\n      if (data.action === \"resolve\") {\n        comments[idx].resolved = !comments[idx].resolved;\n      } else if (data.action === \"reply\") {\n        if (!comments[idx].replies) comments[idx].replies = [];\n        comments[idx].replies?.push({\n          id: crypto.randomUUID().slice(0, 8),\n          name: data.name,\n          text: data.text,\n          timestamp: Date.now(),\n        });\n      }\n      await client\n        .from(table)\n        .upsert({ page_id: pageId, data: comments }, { onConflict: \"page_id\" });\n      return comments[idx];\n    },\n    async deleteComment(pageId, commentId) {\n      const comments = await this.getComments(pageId);\n      const filtered = comments.filter(\n        (c: PageCommentsComment) => c.id !== commentId,\n      );\n      await client\n        .from(table)\n        .upsert({ page_id: pageId, data: filtered }, { onConflict: \"page_id\" });\n    },\n  };\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/default/blocks/collaboration/page-comments/components/elements/page-comments-redis.ts",
      "content": "import type {\n  PageCommentsAdapter,\n  PageCommentsComment,\n} from \"./page-comments-adapters\";\n\ninterface RedisAdapterOptions {\n  client: {\n    get: (key: string) => Promise<string | null>;\n    set: (key: string, value: string) => Promise<unknown>;\n  };\n  prefix?: string;\n}\n\nexport function redisAdapter({\n  client,\n  prefix = \"page-comments\",\n}: RedisAdapterOptions): PageCommentsAdapter {\n  async function getAll(pageId: string): Promise<PageCommentsComment[]> {\n    const raw = await client.get(`${prefix}:${pageId}`);\n    if (!raw) return [];\n    return JSON.parse(raw);\n  }\n\n  async function setAll(pageId: string, comments: PageCommentsComment[]) {\n    await client.set(`${prefix}:${pageId}`, JSON.stringify(comments));\n  }\n\n  return {\n    async getComments(pageId) {\n      return getAll(pageId);\n    },\n    async addComment(pageId, data) {\n      const comments = await getAll(pageId);\n      const comment: PageCommentsComment = {\n        id: crypto.randomUUID().slice(0, 8),\n        name: data.name,\n        text: data.text,\n        ...(data.quote ? { quote: data.quote } : {}),\n        x: data.x,\n        y: data.y,\n        timestamp: Date.now(),\n        resolved: false,\n      };\n      comments.push(comment);\n      await setAll(pageId, comments);\n      return comment;\n    },\n    async updateComment(pageId, commentId, data) {\n      const comments = await getAll(pageId);\n      const idx = comments.findIndex((c) => c.id === commentId);\n      if (idx === -1) return null;\n\n      if (data.action === \"resolve\") {\n        comments[idx].resolved = !comments[idx].resolved;\n      } else if (data.action === \"reply\") {\n        if (!comments[idx].replies) comments[idx].replies = [];\n        comments[idx].replies?.push({\n          id: crypto.randomUUID().slice(0, 8),\n          name: data.name,\n          text: data.text,\n          timestamp: Date.now(),\n        });\n      }\n      await setAll(pageId, comments);\n      return comments[idx];\n    },\n    async deleteComment(pageId, commentId) {\n      const comments = await getAll(pageId);\n      await setAll(\n        pageId,\n        comments.filter((c) => c.id !== commentId),\n      );\n    },\n  };\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "collaboration"
  ],
  "type": "registry:block"
}