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
 
 ![Github Traffic](/static/images/github-traffic.png)
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"
+                >
+                  &larr; 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}`}