Skip to content

Commit

Permalink
feat: add dashboard for table of contents
Browse files Browse the repository at this point in the history
  • Loading branch information
alex-guoba committed Apr 3, 2024
1 parent a851a9e commit 884bd3a
Show file tree
Hide file tree
Showing 7 changed files with 277 additions and 46 deletions.
83 changes: 41 additions & 42 deletions app/(blog)/article/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { env } from "@/env.mjs";
import { ContentLoadingSkeleton } from "@/components/post-skeleton";
import { NotionApiCache } from "@/app/notion/cache";
import { ArticlePost, dbQueryParams } from "@/app/notion/fitler";
import { getTableOfContents } from "@/app/notion/toc";
import { DashboardTableOfContents } from "@/components/layouts/toc";

// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate
export const revalidate = env.REVALIDATE_PAGES; // revalidate the data interval
Expand Down Expand Up @@ -79,56 +81,53 @@ async function parseSlug(slug: string[]) {
return { pageID, lastEditTime, title, summary };
}

async function ContentRender({ pageID }: { pageID: string }) {
const blocks = await NotionApiCache.RetrieveBlockChildren(pageID);
if (!blocks) {
return <div />;
}

return (
<section className="mt-8 flex w-full flex-col gap-y-0.5">
{blocks.map((block: any) => (
<RenderBlock key={block.id} block={block}></RenderBlock>
))}
</section>
);
}

export default async function Page({ params }: { params: { slug: string[] } }) {
// retrieve page meta info by page ID
const { pageID, lastEditTime, title } = await parseSlug(params.slug);
if (!pageID || !title) {
console.log("Post not found or unpublished", pageID, title);
return notFound();
}
const blocks = await NotionApiCache.RetrieveBlockChildren(pageID);
if (!blocks) {
return <div />;
}
const toc = getTableOfContents(blocks);
const has_toc = toc.items.length > 0;

return (
<Shell as="article" className="relative flex min-h-screen flex-col">
<PageHeader>
<PageHeaderHeading>{title}</PageHeaderHeading>
<PageHeaderDescription size="sm" className="text-center">
{formatDate(lastEditTime)}
</PageHeaderDescription>
</PageHeader>
{/* <Separator className="mb-2.5" /> */}

<React.Suspense fallback={<ContentLoadingSkeleton></ContentLoadingSkeleton>}>
<ContentRender pageID={pageID}></ContentRender>
</React.Suspense>
{/* <section className="flex w-full flex-col gap-y-0.5 mt-8">
{blocks.map((block: any) => (
<RenderBlock key={block.id} block={block}></RenderBlock>
))}
</section> */}
{/* </div> */}

{/* </React.Suspense> */}
{/* </section> */}
<Link href="/" className={cn(buttonVariants({ variant: "ghost", className: "mx-auto mt-4 w-fit" }))}>
<ChevronLeftIcon className="mr-2 h-4 w-4" aria-hidden="true" />
See all posts
<span className="sr-only">See all posts</span>
</Link>
</Shell>
<section className={cn("lg:gap-10 xl:grid", has_toc ? "xl:grid-cols-[1fr_300px]" : "")}>
<Shell as="article" className={cn("relative mx-auto flex min-h-screen flex-col", has_toc ? "xl:pl-[150px]" : "")}>
<PageHeader>
<PageHeaderHeading>{title}</PageHeaderHeading>
<PageHeaderDescription size="sm" className="text-center">
{formatDate(lastEditTime)}
</PageHeaderDescription>
</PageHeader>
{/* <Separator className="mb-2.5" /> */}

<React.Suspense fallback={<ContentLoadingSkeleton></ContentLoadingSkeleton>}>
<section className="mt-8 flex w-full flex-col gap-y-0.5">
{blocks.map((block: any) => (
<RenderBlock key={block.id} block={block}></RenderBlock>
))}
</section>
</React.Suspense>

<Link href="/" className={cn(buttonVariants({ variant: "ghost", className: "mx-auto mt-4 w-fit" }))}>
<ChevronLeftIcon className="mr-2 h-4 w-4" aria-hidden="true" />
See all posts
<span className="sr-only">See all posts</span>
</Link>
</Shell>

{has_toc && (
<aside className="hidden text-sm xl:block">
<div className="sticky top-16 -mt-10 max-h-[calc(var(--vh)-4rem)] overflow-y-auto pt-10">
<DashboardTableOfContents toc={toc} />
</div>
</aside>
)}
</section>
);
}
4 changes: 2 additions & 2 deletions app/notion/_components/heading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { VariantProps, cva } from "class-variance-authority";
import React from "react";
import { RenderBlock } from "../render";

const headingVariants = cva("font-bold break-words dark:text-white mt-1.5", {
const headingVariants = cva("font-bold break-words dark:text-white mt-1.5 scroll-mt-16 tracking-tight", {
variants: {
variant: {
heading_1: "py-4 text-3xl",
Expand Down Expand Up @@ -52,7 +52,7 @@ export function HeadingRender({
// <div key={id} =>
<details key={id} className={cn(className, style)}>
<summary>
<Comp key={id} id={id} className={cn(headingVariants({ variant }), "inline-block")} {...props}>
<Comp id={id} className={cn(headingVariants({ variant }), "inline-block")} {...props}>
<RichText title={rich_text} />
</Comp>
</summary>
Expand Down
2 changes: 1 addition & 1 deletion app/notion/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const getBlockID = (block: any): string => {
// Returns a paginated array of child block objects contained in the block using the ID specified.
// see: https://developers.notion.com/reference/get-block-children
// NOTE: Calling a memoized function outside of a component will not use the cache.
export const RetrieveBlockChildren = cache(async (block_id: string): Promise<any> => {
export const RetrieveBlockChildren = cache(async (block_id: string): Promise<Array<any>> => {
const start = new Date().getTime();

const blockId = block_id.replaceAll("-", ""); // ???
Expand Down
66 changes: 66 additions & 0 deletions app/notion/toc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { rawText } from "./block-parse";

const heading_level_1 = "heading_1";
const heading_level_2 = "heading_2";
const heading_level_3 = "heading_3";

interface Item {
title: string;
id: string;
items: Item[];
}

interface Items {
items: Item[];
}

export type TableOfContents = Items;

export function getTableOfContents(blocks: Array<any>): TableOfContents {
const root: TableOfContents = {
items: new Array(),
};

blocks.map((block: any) => {
const { id, type } = block;
if (type != heading_level_1 && type != heading_level_2 && type != heading_level_3) {
return;
}

const heading = block[type];
const raw_context = rawText(heading?.rich_text);
if (!raw_context) {
return;
}

const item: Item = {
title: raw_context,
id: id,
items: new Array(),
};

if (type == heading_level_1) {
root.items.push(item);
} else if (type == heading_level_2) {
const parent = root.items[root.items.length - 1];
if (parent) {
parent.items.push(item);
} else {
root.items.push(item);
}
} else {
const grandparent = root.items[root.items.length - 1];
if (grandparent) {
const parent = grandparent.items[grandparent.items.length - 1];
if (parent) {
parent.items.push(item);
} else {
grandparent.items.push(item);
}
} else {
root.items.push(item);
}
}
});
return root;
}
155 changes: 155 additions & 0 deletions components/layouts/toc.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"use client";

import * as React from "react";

import { TableOfContents } from "@/app/notion/toc";
import { cn } from "@/lib/utils";
import { useMounted } from "@/lib/hooks/use-mounted";

interface TocProps {
toc: TableOfContents;
}

export function DashboardTableOfContents({ toc }: TocProps) {
const itemIds = React.useMemo(
() =>
toc.items
? toc.items
.flatMap((heading1) => [
heading1.id,
heading1.items?.map((heading2) => [heading2.id, heading2.items?.map((heading3) => [heading3.id])]),
])
.flat(4)
.filter(Boolean)
: [],
[toc]
);
const activeHeading = useActiveItem(itemIds);
const mounted = useMounted();

if (!toc?.items) {
return null;
}

return mounted ? (
<div className="space-y-2">
<p className="font-medium">On This Page</p>
{/* <Tree tree={toc} activeItem={activeHeading} /> */}
<ul className={cn("m-0 list-none")}>
<IndentTree tree={toc} activeItem={activeHeading} />
</ul>
</div>
) : null;
}

function useActiveItem(itemIds: (string | undefined)[]) {
const [activeId, setActiveId] = React.useState<string>("");

React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
// entries.forEach((entry) => {
// if (entry.isIntersecting) {
// console.log("actived", entry.target.id);
// // setActiveId(entry.target.id);
// }
// });

entries.some((entry) => {
if (entry.isIntersecting) {
// console.log("the active", entry.target.id);
setActiveId(entry.target.id);
return true;
}
});
},
{ rootMargin: `-64px 0px 0px 0px` }
// {threshold: 0.5,}
);

itemIds?.forEach((id) => {
if (!id) {
return;
}
const element = document.getElementById(id);
if (element) {
observer.observe(element);
}
});

return () => {
itemIds?.forEach((id) => {
if (!id) {
return;
}

const element = document.getElementById(id);
if (element) {
observer.unobserve(element);
}
});
};
}, [itemIds]);

return activeId;
}

interface TreeProps {
tree: TableOfContents;
level?: number;
activeItem?: string | null;
}

function Tree({ tree, level = 1, activeItem }: TreeProps) {
return tree?.items?.length && level <= 3 ? (
<ul className={cn("m-0 list-none", { "pl-4": level !== 1 })}>
{tree.items.map((item, index) => {
return (
<li key={index} className={cn("mt-0 pt-2")}>
<a
href={`#${item.id}`}
className={cn(
"inline-block no-underline",
item.id === `${activeItem}` ? "font-medium text-primary" : "text-sm text-muted-foreground"
)}
>
{item.title}
</a>
{item.items?.length ? <Tree tree={item} level={level + 1} activeItem={activeItem} /> : null}
</li>
);
})}
</ul>
) : null;
}

function IndentTree({ tree, level = 1, activeItem }: TreeProps) {
let style = "pl-4";
if (level == 2) {
style = "pl-8";
} else if (level == 3) {
style = "pl-12";
}
return tree?.items?.length && level <= 3 ? (
<React.Fragment>
{tree.items.map((item, index) => {
return (
<>
<li key={index} className={cn("mt-0 pt-2", style)}>
<a
href={`#${item.id}`}
className={cn(
"inline-block",
item.id === `${activeItem}` ? "font-medium text-primary" : "text-sm text-muted-foreground"
)}
>
{item.title}
</a>
</li>
{item.items?.length ? <IndentTree tree={item} level={level + 1} activeItem={activeItem} /> : null}
</>
);
})}
</React.Fragment>
) : null;
}
2 changes: 1 addition & 1 deletion components/tag-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function TagFooter({ posts }: { posts: TypePostList }) {
<div>
<Separator className="my-8" />
<p className="py-6 font-bold antialiased">Explore articles by category:</p>
<TagList tagCounter={tagCounter} className="grid-cols-3 gap-0.5 md:grid-cols-4 lg:grid-cols-5" />;
<TagList tagCounter={tagCounter} className="grid-cols-3 gap-0.5 md:grid-cols-4 lg:grid-cols-5" />
</div>
);
}
11 changes: 11 additions & 0 deletions lib/hooks/use-mounted.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as React from "react"

export function useMounted() {
const [mounted, setMounted] = React.useState(false)

React.useEffect(() => {
setMounted(true)
}, [])

return mounted
}

0 comments on commit 884bd3a

Please sign in to comment.