Files
ar.badieiandCursor c2376b3b9e
Build & Push Docker Image / build (push) Has been cancelled
feat: redesign landing page with brand system and section blocks
Refresh the homepage around new hero/services/roadmap sections, shared design tokens, and updated brand assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 17:00:55 +03:30

274 lines
8.4 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 } from "@/lib/typography";
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-e-2 rounded-s-none border-transparent py-1.5 text-sm leading-snug tracking-normal transition-colors hover:text-foreground hover:bg-muted/60",
level === 2 && "pe-3",
level === 3 && "pe-5",
level === 4 && "pe-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-e-2 rounded-s-none py-1.5 pe-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 border-e border-border/50 pe-2 pt-0.5">
{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={cn(type.eyebrow, "mb-3 ms-2")}>
فهرست مطالب
</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-[calc(max(0.5rem,env(safe-area-inset-top,0px))+4.5rem)] 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 transition-colors hover:bg-muted/50",
type.label,
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>
</>
);
}