From a8acb74a129694ebfb42d8b2663f96f867d3fae0 Mon Sep 17 00:00:00 2001 From: cxf213 <cxf213@outlook.com> Date: Mon, 7 Apr 2025 11:45:48 +0800 Subject: [PATCH 1/2] feat: add sticky TOC to post layout Add table of contents to the post sidebar with: - Sticky positioning that keeps TOC visible while scrolling - Consistent link styling with the rest of the site - Max height with overflow scrolling for longer TOCs Improves navigation experience for longer articles. --- app/blog/[...slug]/page.tsx | 10 +- css/tailwind.css | 4 + ...e-of-tailwind-nextjs-starter-blog-v2.0.mdx | 3 +- layouts/PostwithTocLayout.tsx | 188 ++++++++++++++++++ package.json | 1 + 5 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 layouts/PostwithTocLayout.tsx diff --git a/app/blog/[...slug]/page.tsx b/app/blog/[...slug]/page.tsx index af96a2b59b..6e62036a5c 100644 --- a/app/blog/[...slug]/page.tsx +++ b/app/blog/[...slug]/page.tsx @@ -10,6 +10,7 @@ import type { Authors, Blog } from 'contentlayer/generated' import PostSimple from '@/layouts/PostSimple' import PostLayout from '@/layouts/PostLayout' import PostBanner from '@/layouts/PostBanner' +import PostwithTocLayout from '@/layouts/PostwithTocLayout' import { Metadata } from 'next' import siteMetadata from '@/data/siteMetadata' import { notFound } from 'next/navigation' @@ -19,6 +20,7 @@ const layouts = { PostSimple, PostLayout, PostBanner, + PostwithTocLayout, } export async function generateMetadata(props: { @@ -112,7 +114,13 @@ export default async function Page(props: { params: Promise<{ slug: string[] }> type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> - <Layout content={mainContent} authorDetails={authorDetails} next={next} prev={prev}> + <Layout + content={mainContent} + authorDetails={authorDetails} + next={next} + prev={prev} + toc={post.toc} + > <MDXLayoutRenderer code={post.body.code} components={components} toc={post.toc} /> </Layout> </> diff --git a/css/tailwind.css b/css/tailwind.css index 5a8a4c2f15..9af79ef48e 100644 --- a/css/tailwind.css +++ b/css/tailwind.css @@ -173,3 +173,7 @@ input:-webkit-autofill:focus { display: inline-block; vertical-align: middle; } + +.toc-container a { + @apply text-primary-500 hover:text-primary-600 dark:hover:text-primary-400; +} diff --git a/data/blog/release-of-tailwind-nextjs-starter-blog-v2.0.mdx b/data/blog/release-of-tailwind-nextjs-starter-blog-v2.0.mdx index 37afcbbf9b..3a2037cc9a 100644 --- a/data/blog/release-of-tailwind-nextjs-starter-blog-v2.0.mdx +++ b/data/blog/release-of-tailwind-nextjs-starter-blog-v2.0.mdx @@ -6,14 +6,13 @@ tags: ['next-js', 'tailwind', 'guide', 'feature'] draft: false summary: 'Release of Tailwind Nextjs Starter Blog template v2.0, refactored with Nextjs App directory and React Server Components setup.Discover the new features and how to migrate from V1.' images: ['/static/images/twitter-card.png'] +layout: PostwithTocLayout --- ## Introduction Welcome to the release of Tailwind Nextjs Starter Blog template v2.0. This release is a major refactor of the codebase to support Nextjs App directory and React Server Components. Read on to discover the new features and how to migrate from V1. -<TOCInline toc={props.toc} exclude="Introduction" /> - ## V1 to V2  diff --git a/layouts/PostwithTocLayout.tsx b/layouts/PostwithTocLayout.tsx new file mode 100644 index 0000000000..ee21a98d04 --- /dev/null +++ b/layouts/PostwithTocLayout.tsx @@ -0,0 +1,188 @@ +import { ReactNode } from 'react' +import { CoreContent } from 'pliny/utils/contentlayer' +import type { Blog, Authors } from 'contentlayer/generated' +import Comments from '@/components/Comments' +import Link from '@/components/Link' +import PageTitle from '@/components/PageTitle' +import SectionContainer from '@/components/SectionContainer' +import Image from '@/components/Image' +import Tag from '@/components/Tag' +import siteMetadata from '@/data/siteMetadata' +import ScrollTopAndComment from '@/components/ScrollTopAndComment' +import TOCInline from 'pliny/ui/TOCInline' + +const editUrl = (path) => `${siteMetadata.siteRepo}/blob/main/data/${path}` +const discussUrl = (path) => + `https://mobile.twitter.com/search?q=${encodeURIComponent(`${siteMetadata.siteUrl}/${path}`)}` + +const postDateTemplate: Intl.DateTimeFormatOptions = { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', +} + +interface LayoutProps { + content: CoreContent<Blog> + authorDetails: CoreContent<Authors>[] + next?: { path: string; title: string } + prev?: { path: string; title: string } + children: ReactNode + // eslint-disable-next-line @typescript-eslint/no-explicit-any + toc: any +} + +export default function PostwithTocLayout({ + content, + authorDetails, + next, + prev, + children, + toc, +}: LayoutProps) { + const { filePath, path, slug, date, title, tags } = content + const basePath = path.split('/')[0] + + return ( + <SectionContainer> + <ScrollTopAndComment /> + <article> + <div className="xl:divide-y xl:divide-gray-200 xl:dark:divide-gray-700"> + <header className="pt-6 xl:pb-6"> + <div className="space-y-1 text-center"> + <dl className="space-y-10"> + <div> + <dt className="sr-only">Published on</dt> + <dd className="text-base leading-6 font-medium text-gray-500 dark:text-gray-400"> + <time dateTime={date}> + {new Date(date).toLocaleDateString(siteMetadata.locale, postDateTemplate)} + </time> + </dd> + </div> + </dl> + <div> + <PageTitle>{title}</PageTitle> + </div> + </div> + </header> + <div className="grid-rows-[auto_1fr] divide-y divide-gray-200 pb-8 xl:grid xl:grid-cols-4 xl:gap-x-6 xl:divide-y-0 dark:divide-gray-700"> + <dl className="pt-6 pb-10 xl:border-b xl:border-gray-200 xl:pt-11 xl:dark:border-gray-700"> + <dt className="sr-only">Authors</dt> + <dd> + <ul className="flex flex-wrap justify-center gap-4 sm:space-x-12 xl:block xl:space-y-8 xl:space-x-0"> + {authorDetails.map((author) => ( + <li className="flex items-center space-x-2" key={author.name}> + {author.avatar && ( + <Image + src={author.avatar} + width={38} + height={38} + alt="avatar" + className="h-10 w-10 rounded-full" + /> + )} + <dl className="text-sm leading-5 font-medium whitespace-nowrap"> + <dt className="sr-only">Name</dt> + <dd className="text-gray-900 dark:text-gray-100">{author.name}</dd> + <dt className="sr-only">Twitter</dt> + <dd> + {author.twitter && ( + <Link + href={author.twitter} + className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400" + > + {author.twitter + .replace('https://twitter.com/', '@') + .replace('https://x.com/', '@')} + </Link> + )} + </dd> + </dl> + </li> + ))} + </ul> + </dd> + </dl> + <div className="divide-y divide-gray-200 xl:col-span-3 xl:row-span-2 xl:pb-0 dark:divide-gray-700"> + <div className="prose dark:prose-invert max-w-none pt-10 pb-8">{children}</div> + <div className="pt-6 pb-6 text-sm text-gray-700 dark:text-gray-300"> + <Link href={discussUrl(path)} rel="nofollow"> + Discuss on Twitter + </Link> + {` • `} + <Link href={editUrl(filePath)}>View on GitHub</Link> + </div> + {siteMetadata.comments && ( + <div + className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300" + id="comment" + > + <Comments slug={slug} /> + </div> + )} + </div> + <footer> + <div className="divide-gray-200 text-sm leading-5 font-medium xl:col-start-1 xl:row-start-2 xl:divide-y dark:divide-gray-700"> + {tags && ( + <div className="py-4 xl:py-8"> + <h2 className="text-xs tracking-wide text-gray-500 uppercase dark:text-gray-400"> + Tags + </h2> + <div className="flex flex-wrap"> + {tags.map((tag) => ( + <Tag key={tag} text={tag} /> + ))} + </div> + </div> + )} + {(next || prev) && ( + <div className="flex justify-between py-4 xl:block xl:space-y-8 xl:py-8"> + {prev && prev.path && ( + <div> + <h2 className="text-xs tracking-wide text-gray-500 uppercase dark:text-gray-400"> + Previous Article + </h2> + <div className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"> + <Link href={`/${prev.path}`}>{prev.title}</Link> + </div> + </div> + )} + {next && next.path && ( + <div> + <h2 className="text-xs tracking-wide text-gray-500 uppercase dark:text-gray-400"> + Next Article + </h2> + <div className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"> + <Link href={`/${next.path}`}>{next.title}</Link> + </div> + </div> + )} + </div> + )} + </div> + {/* Sidebar TOC */} + <div className="py-4 xl:py-8"> + <h2 className="text-xs tracking-wide text-gray-500 uppercase dark:text-gray-400"> + MENU + </h2> + <div className="toc-container mt-3"> + <TOCInline toc={toc} fromHeading={2} toHeading={3} collapse={true} /> + </div> + </div> + {/* Back to blog link */} + <div className="pt-4 xl:pt-8"> + <Link + href={`/${basePath}`} + className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400" + aria-label="Back to the blog" + > + ← Back to the blog + </Link> + </div> + </footer> + </div> + </div> + </article> + </SectionContainer> + ) +} diff --git a/package.json b/package.json index f67158d372..180143d352 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "start": "next dev", + "format": "prettier --write .", "dev": "cross-env INIT_CWD=$PWD next dev", "build": "cross-env INIT_CWD=$PWD next build && cross-env NODE_OPTIONS='--experimental-json-modules' node ./scripts/postbuild.mjs", "serve": "next start", From 44a8e8f7eadfd1a9a0f07418d18490c0a681daae Mon Sep 17 00:00:00 2001 From: cxf213 <cxf213@outlook.com> Date: Mon, 7 Apr 2025 12:23:58 +0800 Subject: [PATCH 2/2] using TABLE OF CONTENTS instead of MENU ; removed 'any'; correct position of toc component --- layouts/PostwithTocLayout.tsx | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/layouts/PostwithTocLayout.tsx b/layouts/PostwithTocLayout.tsx index ee21a98d04..827ffc4013 100644 --- a/layouts/PostwithTocLayout.tsx +++ b/layouts/PostwithTocLayout.tsx @@ -22,14 +22,19 @@ const postDateTemplate: Intl.DateTimeFormatOptions = { day: 'numeric', } +interface TOCItem { + value: string + url: string + depth: number +} + interface LayoutProps { content: CoreContent<Blog> authorDetails: CoreContent<Authors>[] next?: { path: string; title: string } prev?: { path: string; title: string } children: ReactNode - // eslint-disable-next-line @typescript-eslint/no-explicit-any - toc: any + toc: TOCItem[] } export default function PostwithTocLayout({ @@ -42,7 +47,6 @@ export default function PostwithTocLayout({ }: LayoutProps) { const { filePath, path, slug, date, title, tags } = content const basePath = path.split('/')[0] - return ( <SectionContainer> <ScrollTopAndComment /> @@ -159,17 +163,15 @@ export default function PostwithTocLayout({ )} </div> )} - </div> - {/* Sidebar TOC */} - <div className="py-4 xl:py-8"> - <h2 className="text-xs tracking-wide text-gray-500 uppercase dark:text-gray-400"> - MENU - </h2> - <div className="toc-container mt-3"> - <TOCInline toc={toc} fromHeading={2} toHeading={3} collapse={true} /> + <div className="py-4 xl:py-8"> + <h2 className="text-xs tracking-wide text-gray-500 uppercase dark:text-gray-400"> + TABLE OF CONTENTS + </h2> + <div className="toc-container mt-3"> + <TOCInline toc={toc} fromHeading={2} toHeading={3} collapse={true} /> + </div> </div> </div> - {/* Back to blog link */} <div className="pt-4 xl:pt-8"> <Link href={`/${basePath}`}