feat(blog): revamp detail ui, add dynamic table of contents, and integrate giscus comments

This commit is contained in:
Poyraz Avsever
2026-03-30 11:30:13 +03:00
parent 87f6585a78
commit d3c13ed25d
3 changed files with 405 additions and 106 deletions
+113
View File
@@ -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<HTMLDivElement | null>;
};
export function BlogToc({ markdown, scrollerRef }: BlogTocProps) {
const headings = useMemo(() => parseHeadings(markdown), [markdown]);
const [activeId, setActiveId] = useState("");
const observerRef = useRef<IntersectionObserver | null>(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 (
<nav aria-label="İçindekiler" className="space-y-1">
<Typography variant="small" className="mb-2 font-semibold text-foreground">
İçindekiler
</Typography>
{headings.map((heading, index) => (
<button
key={`${heading.id}-${index}`}
type="button"
onClick={() => handleClick(heading.id)}
className={[
"block w-full cursor-pointer truncate border-l-2 text-left text-xs leading-relaxed transition-colors",
heading.level === 3 ? "pl-5" : "pl-3",
activeId === heading.id
? "border-red-600 text-red-600"
: "border-transparent text-muted-foreground hover:text-foreground",
].join(" ")}
>
{heading.text}
</button>
))}
</nav>
);
}