Files
poyraz-portfolio/components/blog-toc.tsx
T

181 lines
4.4 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "@iconify/react";
import { useTranslations } from "next-intl";
import {
Button,
ButtonIcon,
ButtonLabel,
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;
onNavigate?: () => void;
showTitle?: boolean;
};
export function BlogToc({
markdown,
onNavigate,
showTitle = true,
}: BlogTocProps) {
const t = useTranslations("Blog");
const headings = useMemo(() => parseHeadings(markdown), [markdown]);
const [activeId, setActiveId] = useState("");
const rafRef = useRef(0);
const handleClick = useCallback(
(id: string) => {
const target = document.getElementById(id);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "start" });
setActiveId(id);
onNavigate?.();
},
[onNavigate],
);
const handleBackToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
onNavigate?.();
}, [onNavigate]);
useEffect(() => {
if (headings.length === 0) return;
const headingElements = headings
.map((heading) => ({
id: heading.id,
element: document.getElementById(heading.id),
}))
.filter((item): item is { id: string; element: HTMLElement } =>
Boolean(item.element),
);
if (headingElements.length === 0) return;
const updateActive = () => {
let current = headingElements[0].id;
for (const item of headingElements) {
if (item.element.getBoundingClientRect().top <= 120) {
current = item.id;
continue;
}
break;
}
setActiveId(current);
};
const observer = new IntersectionObserver(
() => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(updateActive);
},
{
root: null,
rootMargin: "-120px 0px -65% 0px",
threshold: [0, 1],
},
);
for (const item of headingElements) {
observer.observe(item.element);
}
const timer = setTimeout(updateActive, 200);
return () => {
observer.disconnect();
cancelAnimationFrame(rafRef.current);
clearTimeout(timer);
};
}, [headings]);
if (headings.length < 2) return null;
return (
<nav
aria-label={t("toc")}
className="max-h-[calc(100dvh-7rem)] space-y-1 overflow-y-auto overscroll-contain pr-1 [scrollbar-width:thin]"
>
{showTitle ? (
<Typography
variant="small"
className="mb-2 font-semibold text-foreground"
>
{t("toc")}
</Typography>
) : null}
{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>
))}
<div className="mt-3 border-t border-border pt-3">
<Button
type="button"
variant="outline"
size="xs"
radius="sm"
effect="swap"
swapTarget="both"
onClick={handleBackToTop}
className="w-full justify-between"
>
<ButtonLabel>{t("backToTop")}</ButtonLabel>
<ButtonIcon>
<Icon icon="mdi:arrow-up" width={14} height={14} />
</ButtonIcon>
</Button>
</div>
</nav>
);
}