| 1 | import { useCallback, useEffect, useState } from "react"; |
| 2 | import { Check, Copy } from "lucide-react"; |
| 3 | import { useTranslation } from "react-i18next"; |
| 4 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; |
| 5 | import { |
| 6 | oneDark, |
| 7 | oneLight, |
| 8 | } from "react-syntax-highlighter/dist/esm/styles/prism"; |
| 9 | |
| 10 | import { cn } from "@/lib/utils"; |
| 11 | |
| 12 | interface CodeBlockProps { |
| 13 | language?: string; |
| 14 | code: string; |
| 15 | className?: string; |
| 16 | } |
| 17 | |
| 18 | /** Read dark mode straight from the DOM — stays in sync with Tailwind's `dark:`. */ |
| 19 | function useIsDark() { |
| 20 | const [isDark, setIsDark] = useState(() => |
| 21 | typeof document !== "undefined" |
| 22 | ? document.documentElement.classList.contains("dark") |
| 23 | : true, |
| 24 | ); |
| 25 | |
| 26 | useEffect(() => { |
| 27 | const el = document.documentElement; |
| 28 | const observer = new MutationObserver(() => { |
| 29 | setIsDark(el.classList.contains("dark")); |
| 30 | }); |
| 31 | observer.observe(el, { attributeFilter: ["class"] }); |
| 32 | return () => observer.disconnect(); |
| 33 | }, []); |
| 34 | |
| 35 | return isDark; |
| 36 | } |
| 37 | |
| 38 | export function CodeBlock({ language, code, className }: CodeBlockProps) { |
| 39 | const { t } = useTranslation(); |
| 40 | const [copied, setCopied] = useState(false); |
| 41 | const isDark = useIsDark(); |
| 42 | |
| 43 | const onCopy = useCallback(() => { |
| 44 | if (!navigator.clipboard) return; |
| 45 | navigator.clipboard.writeText(code).then(() => { |
| 46 | setCopied(true); |
| 47 | setTimeout(() => setCopied(false), 1_500); |
| 48 | }); |
| 49 | }, [code]); |
| 50 | |
| 51 | return ( |
| 52 | <div |
| 53 | className={cn( |
| 54 | "overflow-hidden rounded-lg border", |
| 55 | isDark ? "border-white/10" : "border-black/10", |
| 56 | className, |
| 57 | )} |
| 58 | > |
| 59 | <div |
| 60 | className={cn( |
| 61 | "flex items-center justify-between px-4 py-1.5 text-xs font-medium", |
| 62 | isDark |
| 63 | ? "bg-zinc-800 text-zinc-300" |
| 64 | : "bg-zinc-100 text-zinc-600", |
| 65 | )} |
| 66 | > |
| 67 | <span className="lowercase font-mono"> |
| 68 | {language || t("code.fallbackLanguage")} |
| 69 | </span> |
| 70 | <button |
| 71 | type="button" |
| 72 | onClick={onCopy} |
| 73 | className={cn( |
| 74 | "inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono transition-colors", |
| 75 | isDark |
| 76 | ? "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200" |
| 77 | : "text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700", |
| 78 | )} |
| 79 | aria-label={t("code.copyAria")} |
| 80 | > |
| 81 | {copied ? ( |
| 82 | <Check className="h-3.5 w-3.5" /> |
| 83 | ) : ( |
| 84 | <Copy className="h-3.5 w-3.5" /> |
| 85 | )} |
| 86 | <span>{copied ? t("code.copied") : t("code.copy")}</span> |
| 87 | </button> |
| 88 | </div> |
| 89 | <SyntaxHighlighter |
| 90 | language={language} |
| 91 | style={isDark ? oneDark : oneLight} |
| 92 | customStyle={{ |
| 93 | margin: 0, |
| 94 | padding: "1rem", |
| 95 | fontSize: "0.875rem", |
| 96 | lineHeight: 1.6, |
| 97 | }} |
| 98 | PreTag="pre" |
| 99 | wrapLongLines |
| 100 | > |
| 101 | {code} |
| 102 | </SyntaxHighlighter> |
| 103 | </div> |
| 104 | ); |
| 105 | } |
| 106 |