CopyPageButton.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. import React, { useState, useEffect, useRef } from "react"
  2. import { useRouter } from "next/router"
  3. const GITHUB_REPO = "Kilo-Org/kilocode"
  4. const GITHUB_BRANCH = "main"
  5. interface CopyPageButtonProps {
  6. className?: string
  7. }
  8. function getRoutePath(asPath: string) {
  9. const path = asPath.split("#")[0].split("?")[0]
  10. return path === "/" ? "/index" : path
  11. }
  12. export function CopyPageButton({ className }: CopyPageButtonProps) {
  13. const router = useRouter()
  14. const [open, setOpen] = useState(false)
  15. const [copied, setCopied] = useState(false)
  16. const [error, setError] = useState(false)
  17. const [isLoading, setIsLoading] = useState(false)
  18. const ref = useRef<HTMLDivElement>(null)
  19. useEffect(() => {
  20. if (copied || error) {
  21. const timer = setTimeout(() => {
  22. setCopied(false)
  23. setError(false)
  24. }, 3000)
  25. return () => clearTimeout(timer)
  26. }
  27. }, [copied, error])
  28. useEffect(() => {
  29. function handleClickOutside(event: MouseEvent) {
  30. if (ref.current && !ref.current.contains(event.target as Node)) {
  31. setOpen(false)
  32. }
  33. }
  34. if (open) {
  35. document.addEventListener("mousedown", handleClickOutside)
  36. }
  37. return () => {
  38. document.removeEventListener("mousedown", handleClickOutside)
  39. }
  40. }, [open])
  41. const handleCopy = async () => {
  42. if (copied || error || isLoading) return
  43. setIsLoading(true)
  44. setOpen(false)
  45. try {
  46. const mdPath = getRoutePath(router.asPath)
  47. const response = await fetch(`/docs/api/raw-markdown?path=${encodeURIComponent(mdPath)}`)
  48. if (!response.ok) {
  49. throw new Error("Failed to fetch markdown")
  50. }
  51. const markdown = await response.text()
  52. await navigator.clipboard.writeText(markdown)
  53. setCopied(true)
  54. } catch (err) {
  55. console.error("Failed to copy page:", err)
  56. setError(true)
  57. } finally {
  58. setIsLoading(false)
  59. }
  60. }
  61. const openGitHubUrl = async (mode: "raw" | "edit") => {
  62. setOpen(false)
  63. try {
  64. const mdPath = getRoutePath(router.asPath)
  65. const response = await fetch(`/docs/api/resolve-path?path=${encodeURIComponent(mdPath)}`)
  66. if (!response.ok) {
  67. throw new Error("Failed to resolve file path")
  68. }
  69. const { filePath } = await response.json()
  70. const url =
  71. mode === "raw"
  72. ? `https://raw.githubusercontent.com/${GITHUB_REPO}/${GITHUB_BRANCH}/${filePath}`
  73. : `https://github.com/${GITHUB_REPO}/edit/${GITHUB_BRANCH}/${filePath}`
  74. window.open(url, "_blank", "noopener,noreferrer")
  75. } catch (err) {
  76. console.error(`Failed to open ${mode} URL:`, err)
  77. }
  78. }
  79. const label = copied ? "Copied" : error ? "Copy failed" : "Copy page"
  80. return (
  81. <>
  82. <div ref={ref} className={`page-actions ${className || ""}`}>
  83. <button
  84. onClick={handleCopy}
  85. disabled={copied || error || isLoading}
  86. className={`action-button ${copied ? "copied" : ""} ${error ? "errored" : ""}`}
  87. aria-label={label}
  88. title="Copy page as markdown for use with LLMs"
  89. >
  90. {copied ? <CheckIcon /> : <CopyIcon />}
  91. <span>{label}</span>
  92. </button>
  93. <button
  94. onClick={() => setOpen(!open)}
  95. className={`chevron-button ${open ? "active" : ""}`}
  96. aria-label="More actions"
  97. aria-expanded={open}
  98. >
  99. <ChevronDownIcon />
  100. </button>
  101. {open && (
  102. <div className="dropdown">
  103. <button className="dropdown-item" onClick={handleCopy} disabled={isLoading}>
  104. <CopyIcon />
  105. <span>Copy page</span>
  106. </button>
  107. <button className="dropdown-item" onClick={() => openGitHubUrl("raw")}>
  108. <FileIcon />
  109. <span>Open markdown</span>
  110. </button>
  111. <button className="dropdown-item" onClick={() => openGitHubUrl("edit")}>
  112. <EditIcon />
  113. <span>Edit page</span>
  114. </button>
  115. </div>
  116. )}
  117. </div>
  118. <style jsx>{`
  119. .page-actions {
  120. position: relative;
  121. display: inline-flex;
  122. align-items: stretch;
  123. }
  124. .action-button {
  125. display: inline-flex;
  126. align-items: center;
  127. gap: 0.5rem;
  128. padding: 0.25rem 0.5rem;
  129. font-size: 0.875rem;
  130. font-weight: 500;
  131. font-family: inherit;
  132. color: var(--text-secondary);
  133. background: var(--bg-secondary);
  134. border: 1px solid var(--border-color);
  135. border-radius: 0.5rem 0 0 0.5rem;
  136. cursor: pointer;
  137. transition: all 0.15s ease;
  138. white-space: nowrap;
  139. }
  140. .page-actions:hover .action-button:not(:disabled),
  141. .page-actions:hover .chevron-button {
  142. background: var(--bg-tertiary, var(--bg-secondary));
  143. color: var(--text-brand);
  144. border-color: var(--text-brand);
  145. }
  146. .action-button:disabled {
  147. cursor: default;
  148. }
  149. .action-button.copied,
  150. .page-actions:hover .action-button.copied {
  151. color: var(--success-color, #22c55e);
  152. border-color: var(--success-color, #22c55e);
  153. background: var(--success-bg, rgba(34, 197, 94, 0.1));
  154. }
  155. .action-button.errored,
  156. .page-actions:hover .action-button.errored {
  157. color: var(--error-color, #ef4444);
  158. border-color: var(--error-color, #ef4444);
  159. background: var(--error-bg, rgba(239, 68, 68, 0.1));
  160. }
  161. .chevron-button {
  162. display: inline-flex;
  163. align-items: center;
  164. justify-content: center;
  165. padding: 0.25rem 0.35rem;
  166. font-family: inherit;
  167. color: var(--text-secondary);
  168. background: var(--bg-secondary);
  169. border: 1px solid var(--border-color);
  170. border-left: none;
  171. border-radius: 0 0.5rem 0.5rem 0;
  172. cursor: pointer;
  173. transition: all 0.15s ease;
  174. }
  175. .chevron-button.active {
  176. color: var(--text-brand);
  177. border-color: var(--text-brand);
  178. }
  179. .dropdown {
  180. position: absolute;
  181. top: calc(100% + 0.375rem);
  182. left: 0;
  183. min-width: 180px;
  184. background: var(--bg-color);
  185. border: 1px solid var(--border-color);
  186. border-radius: 0.5rem;
  187. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  188. z-index: 50;
  189. padding: 0.25rem;
  190. }
  191. .dropdown-item {
  192. display: flex;
  193. align-items: center;
  194. gap: 0.5rem;
  195. width: 100%;
  196. padding: 0.5rem 0.625rem;
  197. font-size: 0.875rem;
  198. font-weight: 500;
  199. font-family: inherit;
  200. color: var(--text-secondary);
  201. background: transparent;
  202. border: none;
  203. border-radius: 0.375rem;
  204. cursor: pointer;
  205. transition: all 0.15s ease;
  206. white-space: nowrap;
  207. text-align: left;
  208. }
  209. .dropdown-item:hover:not(:disabled) {
  210. background: var(--bg-secondary);
  211. color: var(--text-color);
  212. }
  213. .dropdown-item:disabled {
  214. opacity: 0.5;
  215. cursor: default;
  216. }
  217. `}</style>
  218. </>
  219. )
  220. }
  221. function CopyIcon() {
  222. return (
  223. <svg
  224. width="16"
  225. height="16"
  226. viewBox="0 0 16 16"
  227. fill="none"
  228. stroke="currentColor"
  229. strokeWidth="1.5"
  230. strokeLinecap="round"
  231. strokeLinejoin="round"
  232. >
  233. <rect x="5" y="5" width="9" height="9" rx="1.5" />
  234. <path d="M2 10V3.5A1.5 1.5 0 0 1 3.5 2H10" />
  235. </svg>
  236. )
  237. }
  238. function CheckIcon() {
  239. return (
  240. <svg
  241. width="16"
  242. height="16"
  243. viewBox="0 0 16 16"
  244. fill="none"
  245. stroke="currentColor"
  246. strokeWidth="2"
  247. strokeLinecap="round"
  248. strokeLinejoin="round"
  249. >
  250. <path d="M3 8.5L6.5 12L13 4" />
  251. </svg>
  252. )
  253. }
  254. function ChevronDownIcon() {
  255. return (
  256. <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
  257. <path
  258. d="M2.5 4.5L6 8L9.5 4.5"
  259. stroke="currentColor"
  260. strokeWidth="1.5"
  261. strokeLinecap="round"
  262. strokeLinejoin="round"
  263. />
  264. </svg>
  265. )
  266. }
  267. function FileIcon() {
  268. return (
  269. <svg
  270. width="16"
  271. height="16"
  272. viewBox="0 0 16 16"
  273. fill="none"
  274. stroke="currentColor"
  275. strokeWidth="1.5"
  276. strokeLinecap="round"
  277. strokeLinejoin="round"
  278. >
  279. <path d="M9 1H4a1.5 1.5 0 0 0-1.5 1.5v11A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5V5.5L9 1Z" />
  280. <path d="M9 1v5h4.5" />
  281. </svg>
  282. )
  283. }
  284. function EditIcon() {
  285. return (
  286. <svg
  287. width="16"
  288. height="16"
  289. viewBox="0 0 16 16"
  290. fill="none"
  291. stroke="currentColor"
  292. strokeWidth="1.5"
  293. strokeLinecap="round"
  294. strokeLinejoin="round"
  295. >
  296. <path d="M11.5 1.5a2.121 2.121 0 0 1 3 3L5 14l-4 1 1-4 9.5-9.5Z" />
  297. </svg>
  298. )
  299. }