- Adjusted padding for the blog post thumbnail section. - Introduced a custom hook for tracking scroll progress. - Added horizontal scroll progress indicators for both desktop and mobile views in the blog table of contents.
272 lines
8.3 KiB
TypeScript
272 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, 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";
|
|
|
|
function useScrollProgress() {
|
|
const [progress, setProgress] = useState(0);
|
|
|
|
const update = useCallback(() => {
|
|
const { scrollY } = window;
|
|
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
|
if (docHeight <= 0) {
|
|
setProgress(0);
|
|
return;
|
|
}
|
|
const value = Math.min(100, Math.max(0, (scrollY / docHeight) * 100));
|
|
setProgress(value);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
update();
|
|
window.addEventListener("scroll", update, { passive: true });
|
|
window.addEventListener("resize", update);
|
|
return () => {
|
|
window.removeEventListener("scroll", update);
|
|
window.removeEventListener("resize", update);
|
|
};
|
|
}, [update]);
|
|
|
|
return progress;
|
|
}
|
|
|
|
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 scrollProgress = useScrollProgress();
|
|
|
|
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 with scroll progress bar */}
|
|
<aside
|
|
className={cn(
|
|
"hidden lg:block shrink-0 relative",
|
|
"sticky top-24 self-start",
|
|
className
|
|
)}
|
|
>
|
|
{/* Horizontal scroll progress indicator */}
|
|
<div
|
|
className="absolute -top-2 left-0 right-0 h-1 rounded-full bg-muted overflow-hidden"
|
|
aria-hidden
|
|
>
|
|
<div
|
|
className="h-full min-w-[2px] bg-primary transition-[width] duration-150 ease-out rounded-full"
|
|
style={{ width: `${scrollProgress}%` }}
|
|
/>
|
|
</div>
|
|
<p className="mb-3 text-md ms-2 font-semibold uppercase tracking-wider text-muted-foreground">
|
|
فهرست مطالب
|
|
</p>
|
|
<ScrollArea className="h-full pb-8" dir="rtl">
|
|
{tocList}
|
|
</ScrollArea>
|
|
</aside>
|
|
|
|
{/* Mobile: collapsible with horizontal scroll progress bar */}
|
|
<Collapsible
|
|
open={mobileOpen}
|
|
onOpenChange={setMobileOpen}
|
|
className={cn(
|
|
"lg:hidden w-full sticky top-14 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"
|
|
)}
|
|
>
|
|
{/* Horizontal scroll progress indicator (mobile) */}
|
|
<div
|
|
className="absolute right-1 top-1 inset-inline-0 h-1 w-[97%] bg-muted overflow-hidden rounded-full"
|
|
aria-hidden
|
|
>
|
|
<div
|
|
className="h-full bg-primary transition-[width] duration-150 ease-out rounded-full"
|
|
style={{ width: `${scrollProgress}%` }}
|
|
/>
|
|
</div>
|
|
<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>
|
|
</>
|
|
);
|
|
}
|