Files
aragrp/components/blog-toc.tsx
T
2026-02-05 14:54:19 +03:30

223 lines
6.7 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { ChevronDown, List } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { TocItem } from "@/lib/blog-utils";
interface TocGroup {
heading: TocItem;
children: TocItem[];
}
function groupByLevel2(items: TocItem[]): TocGroup[] {
const groups: TocGroup[] = [];
let current: TocGroup | null = null;
for (const item of items) {
if (item.level === 2) {
current = { heading: item, children: [] };
groups.push(current);
} else if (current) {
current.children.push(item);
}
}
return groups;
}
interface BlogTocProps {
items: TocItem[];
className?: string;
}
const tocLinkClass = (
level: number,
isActive: boolean
) =>
cn(
"block rounded-md border-r-2 rounded-s-none border-transparent py-1.5 text-sm transition-colors hover:text-foreground hover:bg-muted/60",
level === 2 && "pr-3",
level === 3 && "pr-5",
level === 4 && "pr-7",
isActive
? "font-medium text-foreground border-primary bg-muted/50"
: "text-muted-foreground"
);
export function BlogToc({ items, className }: BlogTocProps) {
const [activeId, setActiveId] = useState<string | null>(items[0]?.id ?? null);
const [mobileOpen, setMobileOpen] = useState(false);
const [openSections, setOpenSections] = useState<Set<string>>(() => new Set());
const groups = useMemo(() => groupByLevel2(items), [items]);
useEffect(() => {
setOpenSections(new Set(groups.map((g) => g.heading.id)));
}, [groups]);
useEffect(() => {
if (items.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = entry.target.getAttribute("id");
if (id) setActiveId(id);
}
}
},
{ rootMargin: "-80px 0% -70% 0%", threshold: 0 }
);
items.forEach(({ id }) => {
const el = document.getElementById(id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [items]);
useEffect(() => {
if (activeId && groups.length > 0) {
const group = groups.find(
(g) => g.heading.id === activeId || g.children.some((c) => c.id === activeId)
);
if (group) {
setOpenSections((prev) => new Set(prev).add(group.heading.id));
}
}
}, [activeId, groups]);
if (items.length === 0) return null;
const renderTocLink = (item: TocItem, onNav?: () => void) => (
<Link
key={item.id}
href={`#${item.id}`}
onClick={() => {
setMobileOpen(false);
onNav?.();
}}
className={tocLinkClass(item.level, activeId === item.id)}
>
<span className="line-clamp-2">{item.text}</span>
</Link>
);
const tocList = (
<nav aria-label="فهرست مطالب" className="space-y-1">
{groups.map(({ heading, children }) => {
const hasChildren = children.length > 0;
const isOpen = openSections.has(heading.id);
if (!hasChildren) {
return renderTocLink(heading);
}
return (
<Collapsible
key={heading.id}
open={isOpen}
onOpenChange={(open) => {
setOpenSections((prev) => {
const next = new Set(prev);
if (open) next.add(heading.id);
else next.delete(heading.id);
return next;
});
}}
>
<div
className={cn(
"flex items-center gap-0.5 rounded-md border-r-2 rounded-s-none py-1.5 pr-3",
activeId === heading.id ? "border-primary bg-muted/50" : "border-transparent"
)}
>
<Link
href={`#${heading.id}`}
onClick={() => setMobileOpen(false)}
className={cn(
"min-w-0 flex-1 text-sm transition-colors hover:text-foreground hover:bg-muted/60 rounded-md py-0.5 -my-0.5",
activeId === heading.id ? "font-medium text-foreground" : "text-muted-foreground"
)}
>
<span className="line-clamp-2">{heading.text}</span>
</Link>
<CollapsibleTrigger
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors"
aria-label={isOpen ? "بستن" : "باز کردن"}
>
<ChevronDown
className={cn("size-4 transition-transform", isOpen && "rotate-180")}
/>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className="space-y-1 pt-0.5 pr-2 border-r border-border/50">
{children.map((item) => renderTocLink(item))}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</nav>
);
return (
<>
{/* Desktop: sticky sidebar left */}
<aside
className={cn(
"hidden lg:block shrink-0",
"sticky top-24 self-start max-h-[calc(100vh-6rem)]",
className
)}
>
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
فهرست مطالب
</p>
<ScrollArea className="h-full pb-8" dir="rtl">
{tocList}
</ScrollArea>
</aside>
{/* Mobile: collapsible, sticky when scrolling */}
<Collapsible
open={mobileOpen}
onOpenChange={setMobileOpen}
className={cn(
"lg:hidden w-full sticky top-20 z-10 bg-background",
className
)}
>
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm font-medium transition-colors hover:bg-muted/50",
mobileOpen && "rounded-b-none border-b-0"
)}
>
<span className="flex items-center gap-2">
<List className="size-4 text-muted-foreground" />
فهرست مطالب
</span>
<ChevronDown
className={cn("size-4 text-muted-foreground transition-transform", mobileOpen && "rotate-180")}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="max-h-64 overflow-y-auto rounded-b-lg border border-t-0 border-border bg-muted/20 p-3">
{tocList}
</div>
</CollapsibleContent>
</Collapsible>
</>
);
}