diff --git a/components/blog-detail-content.tsx b/components/blog-detail-content.tsx index 682023a..9b0fa57 100644 --- a/components/blog-detail-content.tsx +++ b/components/blog-detail-content.tsx @@ -8,11 +8,33 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; import { Badge, Card, Typography } from "poyraz-ui/atoms"; import type { BlogDetail } from "@/data/blog-detail"; +import { BlogToc } from "@/components/blog-toc"; +import { GiscusComments } from "@/components/giscus-comments"; type BlogDetailContentProps = { post: BlogDetail; }; +function slugify(text: string) { + return text + .toLowerCase() + .replace(/\*\*/g, "") + .replace(/\*/g, "") + .replace(/[^a-zçğıöşü0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function extractText(children: React.ReactNode): string { + if (typeof children === "string") return children; + if (Array.isArray(children)) return children.map(extractText).join(""); + if (children && typeof children === "object" && "props" in children) { + return extractText((children as React.ReactElement<{ children?: React.ReactNode }>).props.children); + } + return ""; +} + function MermaidBlock({ chart }: { chart: string }) { const [svg, setSvg] = useState(""); const [error, setError] = useState(""); @@ -71,6 +93,11 @@ function MermaidBlock({ chart }: { chart: string }) { ); } +const GISCUS_REPO = process.env.NEXT_PUBLIC_GISCUS_REPO || ""; +const GISCUS_REPO_ID = process.env.NEXT_PUBLIC_GISCUS_REPO_ID || ""; +const GISCUS_CATEGORY = process.env.NEXT_PUBLIC_GISCUS_CATEGORY || "Announcements"; +const GISCUS_CATEGORY_ID = process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID || ""; + export function BlogDetailContent({ post }: BlogDetailContentProps) { const scrollerRef = useRef(null); const [progress, setProgress] = useState(0); @@ -89,119 +116,226 @@ export function BlogDetailContent({ post }: BlogDetailContentProps) { setProgress((element.scrollTop / scrollable) * 100); }; + const showGiscus = GISCUS_REPO && GISCUS_REPO_ID && GISCUS_CATEGORY_ID; + return ( <>
-
-
- - {"<- Blog'a dön"} - - -
- {post.category} - - {post.title} - - - {post.author} - {post.date} - {post.readTime} - - - {post.excerpt} - -
- - - {post.title} - - -
- ( - - {children} - - ), - h3: ({ children }) => ( - - {children} - - ), - p: ({ children }) => ( - - {children} - - ), - li: ({ children }) => ( -
  • {children}
  • - ), - blockquote: ({ children }) => ( -
    - {children} -
    - ), - table: ({ children }) => ( -
    - {children}
    -
    - ), - thead: ({ children }) => {children}, - tr: ({ children }) => {children}, - th: ({ children }) => ( - {children} - ), - td: ({ children }) => {children}, - code: ({ className, children }) => { - const match = /language-(\w+)/.exec(className ?? ""); - const codeText = String(children).replace(/\n$/, ""); - const language = match?.[1] ?? ""; - - if (!match) { - return {children}; - } - - if (language === "mermaid") { - return ; - } - - return ( - - {codeText} - - ); - }, - }} +
    +
    +
    + - {post.markdown} - -
    -
    + {"<- Blog'a dön"} + + +
    + {post.category} + + {post.title} + + + {post.author} · {post.date} · {post.readTime} + + + {post.excerpt} + +
    + + + {post.title} + + +
    + { + const text = extractText(children); + const id = slugify(text); + return ( + + {children} + + ); + }, + h2: ({ children }) => { + const text = extractText(children); + const id = slugify(text); + return ( + + {children} + + ); + }, + h3: ({ children }) => { + const text = extractText(children); + const id = slugify(text); + return ( + + {children} + + ); + }, + h4: ({ children }) => { + const text = extractText(children); + const id = slugify(text); + return ( + + {children} + + ); + }, + p: ({ node, children, ...props }) => { + const hasImg = node?.children?.some((c: any) => c.tagName === "img"); + if (hasImg) { + return ( +
    + {children} +
    + ); + } + return ( + + {children} + + ); + }, + ul: ({ children }) => ( +
      {children}
    + ), + ol: ({ children }) => ( +
      {children}
    + ), + li: ({ children }) => ( +
  • + + {children} +
  • + ), + a: ({ href, children }) => ( + + {children} + + ), + blockquote: ({ children }) => ( + +
    + {children} +
    +
    + ), + hr: () => ( +
    + ), + table: ({ children }) => ( + +
    + {children}
    +
    +
    + ), + thead: ({ children }) => {children}, + tr: ({ children }) => {children}, + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + {children} + ), + img: ({ src, alt }) => ( + + {alt + {alt && ( +
    + + {alt} + +
    + )} +
    + ), + code: ({ className, children }) => { + const match = /language-(\w+)/.exec(className ?? ""); + const codeText = String(children).replace(/\n$/, ""); + const language = match?.[1] ?? ""; + + if (!match) { + return ( + + {children} + + ); + } + + if (language === "mermaid") { + return ; + } + + return ( + + {codeText} + + ); + }, + }} + > + {post.markdown} + +
    + + {showGiscus && ( +
    + + Yorumlar + + +
    + )} + +
    + +
    ); diff --git a/components/blog-toc.tsx b/components/blog-toc.tsx new file mode 100644 index 0000000..82068bc --- /dev/null +++ b/components/blog-toc.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Typography } from "poyraz-ui/atoms"; + +type TocHeading = { + id: string; + text: string; + level: 2 | 3; +}; + +function parseHeadings(markdown: string): TocHeading[] { + const headings: TocHeading[] = []; + const lines = markdown.split("\n"); + + for (const line of lines) { + const match = /^(#{2,3})\s+(.+)$/.exec(line.trim()); + if (!match) continue; + + const level = match[1].length as 2 | 3; + const raw = match[2].replace(/\*\*/g, "").replace(/\*/g, "").trim(); + const id = raw + .toLowerCase() + .replace(/[^a-zçğıöşü0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + + if (id && raw) { + headings.push({ id, text: raw, level }); + } + } + + return headings; +} + +type BlogTocProps = { + markdown: string; + scrollerRef: React.RefObject; +}; + +export function BlogToc({ markdown, scrollerRef }: BlogTocProps) { + const headings = useMemo(() => parseHeadings(markdown), [markdown]); + const [activeId, setActiveId] = useState(""); + const observerRef = useRef(null); + + const handleClick = useCallback( + (id: string) => { + const scroller = scrollerRef.current; + if (!scroller) return; + + const target = scroller.querySelector(`#${CSS.escape(id)}`); + if (!target) return; + + target.scrollIntoView({ behavior: "smooth", block: "start" }); + }, + [scrollerRef], + ); + + useEffect(() => { + const scroller = scrollerRef.current; + if (!scroller || headings.length === 0) return; + + observerRef.current = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + setActiveId(entry.target.id); + } + } + }, + { root: scroller, rootMargin: "0px 0px -60% 0px", threshold: 0.1 }, + ); + + const elements = headings + .map((h) => scroller.querySelector(`#${CSS.escape(h.id)}`)) + .filter(Boolean) as Element[]; + + for (const el of elements) { + observerRef.current.observe(el); + } + + return () => { + observerRef.current?.disconnect(); + }; + }, [headings, scrollerRef]); + + if (headings.length < 2) return null; + + return ( + + ); +} diff --git a/components/giscus-comments.tsx b/components/giscus-comments.tsx new file mode 100644 index 0000000..419b9b8 --- /dev/null +++ b/components/giscus-comments.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +type GiscusCommentsProps = { + repo: string; + repoId: string; + category: string; + categoryId: string; + term?: string; +}; + +export function GiscusComments({ + repo, + repoId, + category, + categoryId, + term, +}: GiscusCommentsProps) { + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const script = document.createElement("script"); + script.src = "https://giscus.app/client.js"; + script.setAttribute("data-repo", repo); + script.setAttribute("data-repo-id", repoId); + script.setAttribute("data-category", category); + script.setAttribute("data-category-id", categoryId); + script.setAttribute("data-mapping", term ? "specific" : "pathname"); + if (term) script.setAttribute("data-term", term); + script.setAttribute("data-strict", "0"); + script.setAttribute("data-reactions-enabled", "1"); + script.setAttribute("data-emit-metadata", "0"); + script.setAttribute("data-input-position", "top"); + script.setAttribute("data-theme", "light"); + script.setAttribute("data-lang", "tr"); + script.setAttribute("data-loading", "lazy"); + script.crossOrigin = "anonymous"; + script.async = true; + + container.appendChild(script); + + return () => { + container.innerHTML = ""; + }; + }, [repo, repoId, category, categoryId, term]); + + return
    ; +}