diff --git a/gitbook/README.md b/gitbook/README.md
new file mode 100644
index 0000000..7b9b562
--- /dev/null
+++ b/gitbook/README.md
@@ -0,0 +1,82 @@
+# GitBook 主題架構說明
+
+GitBook 主題提供類似線上文件的閱讀體驗:左側為樹狀導覽、右側顯示文章資訊與公告,內頁則專注呈現 Notion 內容。本文件整理主題的檔案結構、各佈局元件的責任分工,以及常見的使用方式,方便日後整合或擴充。
+
+## 專案結構
+
+```
+.
+├── components/ # 主題專用的視覺元件與工具(導覽清單、目錄抽屜、底部工具列…)
+│ └── ui/dashboard/ # 儀表板頁面使用的額外組件
+├── config.js # 主題設定(首頁導向、導覽開關、Widget 等)
+├── index.js # 統一對外匯出的入口
+├── layouts/ # 依頁面職責拆分的佈局元件
+│ ├── ArchiveLayout.jsx # 歸檔頁
+│ ├── AuthLayouts.jsx # 登入/註冊頁
+│ ├── BaseLayout.jsx # 主架構:左右欄、行動裝置導覽、Loading 與廣告
+│ ├── ListLayouts.jsx # 首頁/文章列表/搜尋結果與儀表板
+│ ├── NotFoundLayout.jsx # 404 頁面
+│ ├── SlugLayout.jsx # 文章詳情頁
+│ └── TaxonomyLayouts.jsx # 分類與標籤索引
+├── style.js # 只對 GitBook 主題生效的全域樣式
+└── README.md # 本說明文件
+```
+
+## 主要佈局與職責
+
+- `LayoutBase`:包住所有內容的骨架,負責導覽抽屜、公告、目錄、回頂按鈕與 Loading 遮罩,並透過 `useGitBookGlobal` 共享狀態(搜尋、TOC、導覽列表)。
+- `LayoutIndex`:讀取 `config.js` 的 `GITBOOK_INDEX_PAGE`,在前端重新導向至指定文章,若找不到對應 slug 會注入錯誤提示。
+- `LayoutPostList` / `LayoutSearch`:此主題採左側導覽管理文章,頁面本身僅回傳空節點,確保路由存在。
+- `LayoutArchive`:使用 `BlogArchiveItem` 依年份渲染歸檔清單。
+- `LayoutSlug`:顯示單篇內容,處理加密文章、Meta 標題、分類/標籤、前後篇與留言,並在內容缺漏時自動導回 404。
+- `LayoutCategoryIndex` / `LayoutTagIndex`:輸出分類與標籤的索引列表,套用統一樣式與 `locale` 文案。
+- `LayoutSignIn` / `LayoutSignUp`:Clerk 驗證元件容器,會先顯示官方預設表單,再渲染 Notion 內容。
+- `LayoutDashboard`:搭配 `components/ui/dashboard/*` 顯示自訂儀表板。
+- `Layout404`:延遲檢查內容載入狀態,若仍無法抓到文章容器便導回首頁。
+
+## 使用說明
+
+1. **在 Next.js 匯入主題佈局**
+ ```jsx
+ import {
+ LayoutBase,
+ LayoutSlug,
+ LayoutIndex,
+ LayoutCategoryIndex
+ } from '@/themes/gitbook'
+
+ const Post = props => (
+
+
+
+ )
+
+ export default Post
+ ```
+ - `LayoutBase` 處理共同框架;內層依頁面需求替換為 `LayoutIndex`、`LayoutArchive`、`LayoutCategoryIndex`…等。
+ - 需要使用 TOC 或導覽狀態時,可透過 `useGitBookGlobal()` 取得 `searchModal`、`tocVisible` 等共享資料。
+
+2. **調整主題設定**
+ - 於 `config.js` 修改選項,或透過環境變數覆寫,例如 `NEXT_PUBLIC_GITBOOK_INDEX_PAGE`、`NEXT_PUBLIC_GITBOOK_AUTO_SORT`。
+ - 常用開關:
+ - `GITBOOK_AUTO_SORT`:自動依分類整理導覽。
+ - `GITBOOK_EXCLUSIVE_COLLAPSE`:導覽是否一次只展開一組。
+ - `GITBOOK_FOLDER_HOVER_EXPAND`:滑鼠懸停是否自動展開導覽資料夾。
+ - `GITBOOK_WIDGET_REVOLVER_MAPS` / `GITBOOK_WIDGET_TO_TOP`:控制右側 Widget。
+
+3. **客製樣式與元件**
+ - 全域樣式集中在 `style.js`,若需調整字體或底色可於此擴充。
+ - 導覽列表、公告、底部工具列等都在 `components/`,可直接替換或新增對應元件。
+ - 儀表板相關 UI 放在 `components/ui/dashboard/`,可拆分或擴充模組化功能。
+
+4. **新增頁面佈局**
+ - 建議在 `layouts/` 下新增檔案並於 `index.js` 匯出,維持與現有架構一致。
+ - 若需要共用狀態,可在新佈局內透過 `useGitBookGlobal` 共享資料,避免重複定義內容。
+
+## 開發提醒
+
+- 本主題內的註解與靜態文字皆已改為臺灣常用的繁體中文,若新增內容請維持相同用語風格。
+- 導覽列表仰賴 `allNavPages`,若調整資料格式請同步更新 `NavPostList` 相關邏輯。
+- 重新導向或動態載入元件時請注意瀏覽器環境判斷(`isBrowser`),避免在 SSR 階段觸發錯誤。
+
+如需額外備註,可持續更新本 README 讓後續維護者快速上手。
diff --git a/gitbook/components/Announcement.js b/gitbook/components/Announcement.js
new file mode 100755
index 0000000..62df1ec
--- /dev/null
+++ b/gitbook/components/Announcement.js
@@ -0,0 +1,21 @@
+// import { useGlobal } from '@/lib/global'
+import dynamic from 'next/dynamic'
+
+const NotionPage = dynamic(() => import('@/components/NotionPage'))
+
+const Announcement = ({ notice, className }) => {
+// const { locale } = useGlobal()
+ if (notice?.blockMap) {
+ return
+
+ {/* {locale.COMMON.ANNOUNCEMENT}
*/}
+ {notice && (
+
+
)}
+
+
+ } else {
+ return <>>
+ }
+}
+export default Announcement
diff --git a/gitbook/components/ArticleAround.js b/gitbook/components/ArticleAround.js
new file mode 100644
index 0000000..02846ac
--- /dev/null
+++ b/gitbook/components/ArticleAround.js
@@ -0,0 +1,41 @@
+import { useGlobal } from '@/lib/global'
+import SmartLink from '@/components/SmartLink'
+
+/**
+ * 上一篇、下一篇文章
+ * @param {prev,next} param0
+ * @returns
+ */
+export default function ArticleAround({ prev, next }) {
+ const { locale } = useGlobal()
+
+ if (!prev || !next) {
+ return <>>
+ }
+
+ return (
+
+
+
+
+
{locale.COMMON.PREV_POST}
+
{prev.title}
+
+
+
+
+
+
{locale.COMMON.NEXT_POST}
+
{next.title}
+
+
+
+
+ )
+}
diff --git a/gitbook/components/ArticleInfo.js b/gitbook/components/ArticleInfo.js
new file mode 100644
index 0000000..27def82
--- /dev/null
+++ b/gitbook/components/ArticleInfo.js
@@ -0,0 +1,17 @@
+/**
+ * 文章補充資訊
+ * @param {*} param0
+ * @returns
+ */
+export default function ArticleInfo({ post }) {
+ if (!post) {
+ return null
+ }
+ return (
+
+
+ Last update:{' '}
+ {post.date?.start_date || post?.publishDay || post?.lastEditedDay}
+
+ )
+}
diff --git a/gitbook/components/ArticleLock.js b/gitbook/components/ArticleLock.js
new file mode 100644
index 0000000..2770739
--- /dev/null
+++ b/gitbook/components/ArticleLock.js
@@ -0,0 +1,67 @@
+import { useGlobal } from '@/lib/global'
+import { useRouter } from 'next/router'
+import { useEffect, useRef } from 'react'
+
+/**
+ * 加密文章驗證元件
+ * @param {password, validPassword} props
+ * @param password 正確的密碼
+ * @param validPassword(bool) 回呼函式,驗證通過時傳回 true
+ * @returns
+ */
+export const ArticleLock = props => {
+ const { validPassword } = props
+ const { locale } = useGlobal()
+ const router = useRouter()
+ const passwordInputRef = useRef(null)
+
+ /**
+ * 輸入並送出密碼
+ */
+ const submitPassword = () => {
+ const p = document.getElementById('password')
+ // 驗證失敗提示
+ if (!validPassword(p?.value)) {
+ const tips = document.getElementById('tips')
+ if (tips) {
+ tips.innerHTML = ''
+ tips.innerHTML = `${locale.COMMON.PASSWORD_ERROR}
`
+ }
+ }
+ }
+
+ useEffect(() => {
+ // 選取密碼輸入框並聚焦
+ passwordInputRef.current.focus()
+ }, [router])
+
+ return (
+
+
+
{locale.COMMON.ARTICLE_LOCK_TIPS}
+
+
{
+ if (e.key === 'Enter') {
+ submitPassword()
+ }
+ }}
+ ref={passwordInputRef} // 綁定 ref 到 passwordInputRef 變數
+ className='outline-none w-full text-sm pl-5 rounded-l transition focus:shadow-lg dark:text-gray-300 font-light leading-10 text-black bg-gray-100 dark:bg-gray-500'>
+
+
+ {locale.COMMON.SUBMIT}
+
+
+
+
+
+
+ )
+}
diff --git a/gitbook/components/BlogArchiveItem.js b/gitbook/components/BlogArchiveItem.js
new file mode 100644
index 0000000..ab11c28
--- /dev/null
+++ b/gitbook/components/BlogArchiveItem.js
@@ -0,0 +1,36 @@
+import SmartLink from '@/components/SmartLink'
+
+/**
+ * 歸檔分組
+ * @param {*} param0
+ * @returns
+ */
+export default function BlogArchiveItem({ archiveTitle, archivePosts }) {
+ return (
+
+ )
+}
diff --git a/gitbook/components/BlogPostCard.js b/gitbook/components/BlogPostCard.js
new file mode 100644
index 0000000..0726d22
--- /dev/null
+++ b/gitbook/components/BlogPostCard.js
@@ -0,0 +1,33 @@
+import Badge from '@/components/Badge'
+import NotionIcon from '@/components/NotionIcon'
+import { siteConfig } from '@/lib/config'
+import SmartLink from '@/components/SmartLink'
+import { useRouter } from 'next/router'
+
+const BlogPostCard = ({ post, className }) => {
+ const router = useRouter()
+ const currentSelected =
+ decodeURIComponent(router.asPath.split('?')[0]) === post?.href
+
+ return (
+
+
+
+ {siteConfig('POST_TITLE_ICON') && (
+
+ )}{' '}
+ {post.title}
+
+ {/* 最新文章加個紅點 */}
+ {post?.isLatest && siteConfig('GITBOOK_LATEST_POST_RED_BADGE') && (
+
+ )}
+
+
+ )
+}
+
+export default BlogPostCard
diff --git a/gitbook/components/BottomMenuBar.js b/gitbook/components/BottomMenuBar.js
new file mode 100644
index 0000000..f264d55
--- /dev/null
+++ b/gitbook/components/BottomMenuBar.js
@@ -0,0 +1,50 @@
+import { useGlobal } from '@/lib/global'
+import { useGitBookGlobal } from '..'
+
+/**
+ * 行動版底部導覽
+ * @param {*} param0
+ * @returns
+ */
+export default function BottomMenuBar({ post, className }) {
+ const showTocButton = post?.toc?.length > 1
+ const { locale } = useGlobal()
+ const { pageNavVisible, changePageNavVisible, tocVisible, changeTocVisible } =
+ useGitBookGlobal()
+ const togglePageNavVisible = () => {
+ changePageNavVisible(!pageNavVisible)
+ }
+
+ const toggleToc = () => {
+ changeTocVisible(!tocVisible)
+ }
+
+ return (
+
+
+
+
+ {showTocButton && (
+
+ )}
+
+
+ )
+}
diff --git a/gitbook/components/Card.js b/gitbook/components/Card.js
new file mode 100644
index 0000000..d24c046
--- /dev/null
+++ b/gitbook/components/Card.js
@@ -0,0 +1,9 @@
+const Card = ({ children, headerSlot, className }) => {
+ return
+ <>{headerSlot}>
+
+
+}
+export default Card
diff --git a/gitbook/components/Catalog.js b/gitbook/components/Catalog.js
new file mode 100644
index 0000000..06c9206
--- /dev/null
+++ b/gitbook/components/Catalog.js
@@ -0,0 +1,104 @@
+import { isBrowser } from '@/lib/utils'
+import throttle from 'lodash.throttle'
+import { uuidToId } from 'notion-utils'
+import { useCallback, useEffect, useState } from 'react'
+
+/**
+ * 目錄導覽元件
+ * @param toc
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const Catalog = ({ post }) => {
+ const toc = post?.toc
+ // 同步選取目錄事件
+ const [activeSection, setActiveSection] = useState(null)
+
+ // 監聽捲動事件
+ useEffect(() => {
+ window.addEventListener('scroll', actionSectionScrollSpy)
+ actionSectionScrollSpy()
+ return () => {
+ window.removeEventListener('scroll', actionSectionScrollSpy)
+ }
+ }, [post])
+
+ const throttleMs = 200
+ const actionSectionScrollSpy = useCallback(
+ throttle(() => {
+ const sections = document.getElementsByClassName('notion-h')
+ let prevBBox = null
+ let currentSectionId = null
+ for (let i = 0; i < sections.length; ++i) {
+ const section = sections[i]
+ if (!section || !(section instanceof Element)) continue
+ if (!currentSectionId) {
+ currentSectionId = section.getAttribute('data-id')
+ }
+ const bbox = section.getBoundingClientRect()
+ const prevHeight = prevBBox ? bbox.top - prevBBox.bottom : 0
+ const offset = Math.max(150, prevHeight / 4)
+ // GetBoundingClientRect returns values relative to viewport
+ if (bbox.top - offset < 0) {
+ currentSectionId = section.getAttribute('data-id')
+ prevBBox = bbox
+ continue
+ }
+ // No need to continue loop, if last element has been detected
+ break
+ }
+ setActiveSection(currentSectionId)
+ const tocIds = post?.toc?.map(t => uuidToId(t.id)) || []
+ const index = tocIds.indexOf(currentSectionId) || 0
+ if (isBrowser && tocIds?.length > 0) {
+ for (const tocWrapper of document?.getElementsByClassName(
+ 'toc-wrapper'
+ )) {
+ tocWrapper?.scrollTo({ top: 28 * index, behavior: 'smooth' })
+ }
+ }
+ }, throttleMs)
+ )
+
+ // 無目錄則直接返回空
+ if (!toc || toc?.length < 1) {
+ return <>>
+ }
+
+ return (
+ <>
+ {/*
+ {locale.COMMON.TABLE_OF_CONTENTS}
+
*/}
+
+
+
+
+ >
+ )
+}
+
+export default Catalog
diff --git a/gitbook/components/CatalogDrawerWrapper.js b/gitbook/components/CatalogDrawerWrapper.js
new file mode 100644
index 0000000..838d000
--- /dev/null
+++ b/gitbook/components/CatalogDrawerWrapper.js
@@ -0,0 +1,66 @@
+import { useGlobal } from '@/lib/global'
+import { useGitBookGlobal } from '@/themes/gitbook'
+import { useRouter } from 'next/router'
+import { useEffect } from 'react'
+import Catalog from './Catalog'
+
+/**
+ * 懸浮抽屜目錄
+ * @param toc
+ * @param post
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const CatalogDrawerWrapper = ({ post, cRef }) => {
+ const { tocVisible, changeTocVisible } = useGitBookGlobal()
+ const { locale } = useGlobal()
+ const router = useRouter()
+ const switchVisible = () => {
+ changeTocVisible(!tocVisible)
+ }
+ useEffect(() => {
+ changeTocVisible(false)
+ }, [router])
+ return (
+ <>
+
+ {/* 側邊選單 */}
+
+ {post && (
+ <>
+
+ {locale.COMMON.TABLE_OF_CONTENTS}
+ {
+ changeTocVisible(false)
+ }}>
+
+
+
+
+ >
+ )}
+
+
+ {/* 背景遮罩 */}
+
+ >
+ )
+}
+export default CatalogDrawerWrapper
diff --git a/gitbook/components/CategoryGroup.js b/gitbook/components/CategoryGroup.js
new file mode 100644
index 0000000..ba43437
--- /dev/null
+++ b/gitbook/components/CategoryGroup.js
@@ -0,0 +1,19 @@
+
+import CategoryItem from './CategoryItem'
+
+const CategoryGroup = ({ currentCategory, categoryOptions }) => {
+ if (!categoryOptions) {
+ return <>>
+ }
+ return
+
分類
+
+ {categoryOptions?.map(category => {
+ const selected = currentCategory === category.name
+ return
+ })}
+
+
+}
+
+export default CategoryGroup
diff --git a/gitbook/components/CategoryItem.js b/gitbook/components/CategoryItem.js
new file mode 100644
index 0000000..8b7cbcf
--- /dev/null
+++ b/gitbook/components/CategoryItem.js
@@ -0,0 +1,18 @@
+import SmartLink from '@/components/SmartLink'
+
+export default function CategoryItem ({ selected, category, categoryCount }) {
+ return (
+
+
+ {category} {categoryCount && `(${categoryCount})`}
+
+
+
+ );
+}
diff --git a/gitbook/components/Footer.js b/gitbook/components/Footer.js
new file mode 100644
index 0000000..d850840
--- /dev/null
+++ b/gitbook/components/Footer.js
@@ -0,0 +1,66 @@
+import { BeiAnGongAn } from '@/components/BeiAnGongAn'
+import { siteConfig } from '@/lib/config'
+import SocialButton from './SocialButton'
+/**
+ * 站點也叫
+ * @param {*} param0
+ * @returns
+ */
+const Footer = ({ siteInfo }) => {
+ const d = new Date()
+ const currentYear = d.getFullYear()
+ const since = siteConfig('SINCE')
+ const copyrightDate =
+ parseInt(since) < currentYear ? since + '-' + currentYear : currentYear
+
+ return (
+
+ )
+}
+
+export default Footer
diff --git a/gitbook/components/Header.js b/gitbook/components/Header.js
new file mode 100644
index 0000000..2c93034
--- /dev/null
+++ b/gitbook/components/Header.js
@@ -0,0 +1,134 @@
+import Collapse from '@/components/Collapse'
+import DarkModeButton from '@/components/DarkModeButton'
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import { SignInButton, SignedOut, UserButton } from '@clerk/nextjs'
+import { useRef, useState } from 'react'
+import CONFIG from '../config'
+import LogoBar from './LogoBar'
+import { MenuBarMobile } from './MenuBarMobile'
+import { MenuItemDrop } from './MenuItemDrop'
+import SearchInput from './SearchInput'
+
+/**
+ * 頁首:頂部導覽列 + 選單
+ * @param {} param0
+ * @returns
+ */
+export default function Header(props) {
+ const { className, customNav, customMenu } = props
+ const [isOpen, changeShow] = useState(false)
+ const collapseRef = useRef(null)
+
+ const { locale } = useGlobal()
+
+ const defaultLinks = [
+ {
+ icon: 'fas fa-th',
+ name: locale.COMMON.CATEGORY,
+ href: '/category',
+ show: siteConfig('GITBOOK_MENU_CATEGORY', null, CONFIG)
+ },
+ {
+ icon: 'fas fa-tag',
+ name: locale.COMMON.TAGS,
+ href: '/tag',
+ show: siteConfig('GITBOOK_BOOK_MENU_TAG', null, CONFIG)
+ },
+ {
+ icon: 'fas fa-archive',
+ name: locale.NAV.ARCHIVE,
+ href: '/archive',
+ show: siteConfig('GITBOOK_MENU_ARCHIVE', null, CONFIG)
+ },
+ {
+ icon: 'fas fa-search',
+ name: locale.NAV.SEARCH,
+ href: '/search',
+ show: siteConfig('GITBOOK_MENU_SEARCH', null, CONFIG)
+ }
+ ]
+
+ let links = defaultLinks.concat(customNav)
+
+ const toggleMenuOpen = () => {
+ changeShow(!isOpen)
+ }
+
+ // 若啟用自訂選單則覆寫頁面內建選單
+ if (siteConfig('CUSTOM_MENU')) {
+ links = customMenu
+ }
+
+ const enableClerk = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
+
+ return (
+
+ {/* 桌面端選單 */}
+
+
+ {/* 左側 */}
+
+
+
+ {/* 桌面端頂部選單 */}
+
+ {links &&
+ links?.map((link, index) => (
+
+ ))}
+
+
+
+ {/* 右側 */}
+
+ {/* 登入相關 */}
+ {enableClerk && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+ {/* 摺疊按鈕,僅行動版顯示 */}
+
+
+
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {/* 行動版摺疊選單 */}
+
+
+
+ collapseRef.current?.updateCollapseHeight(param)
+ }
+ />
+
+
+
+ )
+}
diff --git a/gitbook/components/InfoCard.js b/gitbook/components/InfoCard.js
new file mode 100644
index 0000000..4b6e5cf
--- /dev/null
+++ b/gitbook/components/InfoCard.js
@@ -0,0 +1,20 @@
+import LazyImage from '@/components/LazyImage'
+import Router from 'next/router'
+import SocialButton from './SocialButton'
+import { siteConfig } from '@/lib/config'
+
+const InfoCard = (props) => {
+ const { siteInfo } = props
+ return
+
+
{ Router.push('/about') }}>
+
+
+
{siteConfig('AUTHOR')}
+
{siteConfig('BIO')}
+
+
+
+}
+
+export default InfoCard
diff --git a/gitbook/components/JumpToTopButton.js b/gitbook/components/JumpToTopButton.js
new file mode 100644
index 0000000..4041851
--- /dev/null
+++ b/gitbook/components/JumpToTopButton.js
@@ -0,0 +1,28 @@
+/**
+ * 跳轉至網頁頂端
+ * 當畫面往下滑 500 像素後會顯示此元件
+ * @param targetRef 關聯高度的目標 HTML 標籤
+ * @param showPercent 是否顯示百分比
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const JumpToTopButton = ({ showPercent = false, percent, className }) => {
+ return (
+
+ {
+ window.scrollTo({ top: 0, behavior: 'smooth' })
+ }}
+ />
+
+ )
+}
+
+export default JumpToTopButton
diff --git a/gitbook/components/LeftMenuBar.js b/gitbook/components/LeftMenuBar.js
new file mode 100644
index 0000000..6f9da09
--- /dev/null
+++ b/gitbook/components/LeftMenuBar.js
@@ -0,0 +1,15 @@
+import SmartLink from '@/components/SmartLink'
+
+export default function LeftMenuBar () {
+ return (
+
+ );
+}
diff --git a/gitbook/components/LogoBar.js b/gitbook/components/LogoBar.js
new file mode 100644
index 0000000..166ded5
--- /dev/null
+++ b/gitbook/components/LogoBar.js
@@ -0,0 +1,29 @@
+import LazyImage from '@/components/LazyImage'
+import { siteConfig } from '@/lib/config'
+import SmartLink from '@/components/SmartLink'
+import CONFIG from '../config'
+
+/**
+ * Logo 區域
+ * @param {*} props
+ * @returns
+ */
+export default function LogoBar(props) {
+ const { siteInfo } = props
+ return (
+
+
+
+ {siteInfo?.title || siteConfig('TITLE')}
+
+
+ )
+}
diff --git a/gitbook/components/MenuBarMobile.js b/gitbook/components/MenuBarMobile.js
new file mode 100644
index 0000000..e174fdb
--- /dev/null
+++ b/gitbook/components/MenuBarMobile.js
@@ -0,0 +1,54 @@
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import CONFIG from '../config'
+import { MenuItemCollapse } from './MenuItemCollapse'
+
+export const MenuBarMobile = props => {
+ const { customMenu, customNav } = props
+ const { locale } = useGlobal()
+
+ let links = [
+ // { name: locale.NAV.INDEX, href: '/' || '/', show: true },
+ {
+ name: locale.COMMON.CATEGORY,
+ href: '/category',
+ show: siteConfig('GITBOOK_MENU_CATEGORY', null, CONFIG)
+ },
+ {
+ name: locale.COMMON.TAGS,
+ href: '/tag',
+ show: siteConfig('GITBOOK_BOOK_MENU_TAG', null, CONFIG)
+ },
+ {
+ name: locale.NAV.ARCHIVE,
+ href: '/archive',
+ show: siteConfig('GITBOOK_MENU_ARCHIVE', null, CONFIG)
+ }
+ // { name: locale.NAV.SEARCH, href: '/search', show: siteConfig('MENU_SEARCH', null, CONFIG) }
+ ]
+
+ if (customNav) {
+ links = links.concat(customNav)
+ }
+
+ // 若啟用自訂選單,則不再使用 Page 生成的選單。
+ if (siteConfig('CUSTOM_MENU')) {
+ links = customMenu
+ }
+
+ if (!links || links.length === 0) {
+ return null
+ }
+
+ return (
+
+ )
+}
diff --git a/gitbook/components/MenuItemCollapse.js b/gitbook/components/MenuItemCollapse.js
new file mode 100644
index 0000000..378cdc5
--- /dev/null
+++ b/gitbook/components/MenuItemCollapse.js
@@ -0,0 +1,97 @@
+import Collapse from '@/components/Collapse'
+import SmartLink from '@/components/SmartLink'
+import { useRouter } from 'next/router'
+import { useState } from 'react'
+
+/**
+ * 摺疊選單
+ * @param {*} param0
+ * @returns
+ */
+export const MenuItemCollapse = props => {
+ const { link } = props
+ const [show, changeShow] = useState(false)
+ const hasSubMenu = link?.subMenus?.length > 0
+
+ const [isOpen, changeIsOpen] = useState(false)
+
+ const router = useRouter()
+
+ if (!link || !link.show) {
+ return null
+ }
+
+ const selected = router.pathname === link.href || router.asPath === link.href
+
+ const toggleShow = () => {
+ changeShow(!show)
+ }
+
+ const toggleOpenSubMenu = () => {
+ changeIsOpen(!isOpen)
+ }
+
+ return (
+ <>
+
+ {!hasSubMenu && (
+
+
+
+ )}
+
+ {hasSubMenu && (
+
+ )}
+
+
+ {/* 摺疊子選單 */}
+ {hasSubMenu && (
+
+ {link?.subMenus?.map((sLink, index) => {
+ return (
+
+ )
+ })}
+
+ )}
+ >
+ )
+}
diff --git a/gitbook/components/MenuItemDrop.js b/gitbook/components/MenuItemDrop.js
new file mode 100644
index 0000000..ffbc90e
--- /dev/null
+++ b/gitbook/components/MenuItemDrop.js
@@ -0,0 +1,73 @@
+import SmartLink from '@/components/SmartLink'
+import { useRouter } from 'next/router'
+import { useState } from 'react'
+
+export const MenuItemDrop = ({ link }) => {
+ const [show, changeShow] = useState(false)
+ const router = useRouter()
+
+ if (!link || !link.show) {
+ return null
+ }
+ const hasSubMenu = link?.subMenus?.length > 0
+ const selected = router.pathname === link.href || router.asPath === link.href
+ return (
+ changeShow(true)}
+ onMouseOut={() => changeShow(false)}>
+ {!hasSubMenu && (
+
+
+ {link?.icon && } {link?.name}
+
+
+ )}
+
+ {/* 包含子選單 */}
+ {hasSubMenu && (
+ <>
+
+
+ {link?.icon && } {link?.name}
+ {hasSubMenu && (
+
+ )}
+
+
+ {/* 下拉選單內容 */}
+
+ {link?.subMenus?.map((sLink, index) => {
+ return (
+ -
+
+
+ {link?.icon && }
+ {sLink.title}
+
+
+
+ )
+ })}
+
+ >
+ )}
+
+ )
+}
diff --git a/gitbook/components/MenuItemMobileNormal.js b/gitbook/components/MenuItemMobileNormal.js
new file mode 100644
index 0000000..d04ba79
--- /dev/null
+++ b/gitbook/components/MenuItemMobileNormal.js
@@ -0,0 +1,29 @@
+import SmartLink from '@/components/SmartLink'
+import { useRouter } from 'next/router'
+
+export const NormalMenu = props => {
+ const { link } = props
+ const router = useRouter()
+
+ if (!link || !link.show) {
+ return null
+ }
+
+ const selected = router.pathname === link.href || router.asPath === link.href
+
+ return (
+
+
+ {link.slot}
+
+ )
+}
diff --git a/gitbook/components/MenuItemPCNormal.js b/gitbook/components/MenuItemPCNormal.js
new file mode 100644
index 0000000..06bee8a
--- /dev/null
+++ b/gitbook/components/MenuItemPCNormal.js
@@ -0,0 +1,30 @@
+import SmartLink from '@/components/SmartLink'
+import { useRouter } from 'next/router'
+
+export const MenuItemPCNormal = props => {
+ const { link } = props
+ const router = useRouter()
+ const selected = router.pathname === link.href || router.asPath === link.href
+ if (!link || !link.show) {
+ return null
+ }
+
+ return (
+
+
+ {link.slot}
+
+ )
+}
diff --git a/gitbook/components/NavPostItem.js b/gitbook/components/NavPostItem.js
new file mode 100644
index 0000000..a6102be
--- /dev/null
+++ b/gitbook/components/NavPostItem.js
@@ -0,0 +1,90 @@
+import Badge from '@/components/Badge'
+import Collapse from '@/components/Collapse'
+import { siteConfig } from '@/lib/config'
+import { useEffect, useState } from 'react'
+import BlogPostCard from './BlogPostCard'
+
+/**
+ * 導覽列表
+ * @param posts 所有文章
+ * @param tags 所有標籤
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const NavPostItem = props => {
+ const { group, expanded, toggleItem } = props // 接收傳入的展開狀態與切換函式
+ const hoverExpand = siteConfig('GITBOOK_FOLDER_HOVER_EXPAND')
+ const [isTouchDevice, setIsTouchDevice] = useState(false)
+
+ // 偵測是否為觸控裝置
+ useEffect(() => {
+ const checkTouchDevice = () => {
+ if (window.matchMedia('(pointer: coarse)').matches) {
+ setIsTouchDevice(true)
+ }
+ }
+ checkTouchDevice()
+
+ // 選用:監聽視窗尺寸變化時重新檢測
+ window.addEventListener('resize', checkTouchDevice)
+ return () => {
+ window.removeEventListener('resize', checkTouchDevice)
+ }
+ }, [])
+
+ // 當展開狀態改變時觸發切換函式並同步內部狀態
+ const toggleOpenSubMenu = () => {
+ toggleItem() // 呼叫父元件傳入的切換函式
+ }
+ const onHoverToggle = () => {
+ // 允許滑鼠懸停時自動展開,而非點擊
+ if (!hoverExpand || isTouchDevice) {
+ return
+ }
+ toggleOpenSubMenu()
+ }
+
+ const groupHasLatest = group?.items?.some(post => post.isLatest)
+
+ if (group?.category) {
+ return (
+ <>
+
+
+ {group?.category}
+
+
+
+
+ {groupHasLatest &&
+ siteConfig('GITBOOK_LATEST_POST_RED_BADGE') &&
+ !expanded &&
}
+
+
+ {group?.items?.map((post, index) => (
+
+
+
+ ))}
+
+ >
+ )
+ } else {
+ return (
+ <>
+ {group?.items?.map((post, index) => (
+
+
+
+ ))}
+ >
+ )
+ }
+}
+
+export default NavPostItem
diff --git a/gitbook/components/NavPostList.js b/gitbook/components/NavPostList.js
new file mode 100644
index 0000000..2a5cdac
--- /dev/null
+++ b/gitbook/components/NavPostList.js
@@ -0,0 +1,165 @@
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import { useRouter } from 'next/router'
+import { useEffect, useState } from 'react'
+import CONFIG from '../config'
+import BlogPostCard from './BlogPostCard'
+import NavPostItem from './NavPostItem'
+
+/**
+ * 部落格列表滾動分頁
+ * @param posts 所有文章
+ * @param tags 所有標籤
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const NavPostList = props => {
+ const { filteredNavPages } = props
+ const { locale, currentSearch } = useGlobal()
+ const router = useRouter()
+
+ // 依分類將文章歸成資料夾
+ const categoryFolders = groupArticles(filteredNavPages)
+
+ // 存放被展開的群組
+ const [expandedGroups, setExpandedGroups] = useState([])
+
+ // 是否採用排他折疊,一次只展開一個資料夾
+ const GITBOOK_EXCLUSIVE_COLLAPSE = siteConfig(
+ 'GITBOOK_EXCLUSIVE_COLLAPSE',
+ null,
+ CONFIG
+ )
+
+ useEffect(() => {
+ // 展開資料夾
+ setTimeout(() => {
+ const currentPath = decodeURIComponent(router.asPath.split('?')[0])
+ const defaultOpenIndex = getDefaultOpenIndexByPath(
+ categoryFolders,
+ currentPath
+ )
+ setExpandedGroups([defaultOpenIndex])
+ }, 500)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [router, filteredNavPages])
+
+ // 切換折疊項目,當陣列狀態改變時觸發
+ const toggleItem = index => {
+ let newExpandedGroups = [...expandedGroups] // 建立新的展開群組陣列
+
+ // 若 expandedGroups 中不存在則加入,存在則移除
+ if (expandedGroups.includes(index)) {
+ // 若 expandedGroups 中包含 index,則移除
+ newExpandedGroups = newExpandedGroups.filter(
+ expandedIndex => expandedIndex !== index
+ )
+ } else {
+ // 若 expandedGroups 中不含 index,則加入
+ newExpandedGroups.push(index)
+ }
+ // 是否排他
+ if (GITBOOK_EXCLUSIVE_COLLAPSE) {
+ // 若折疊選單為排他模式,僅保留目前群組其餘關閉
+ newExpandedGroups = newExpandedGroups.filter(
+ expandedIndex => expandedIndex === index
+ )
+ }
+
+ // 更新展開群組陣列
+ setExpandedGroups(newExpandedGroups)
+ }
+
+ // 無資料時
+ if (!categoryFolders || categoryFolders.length === 0) {
+ // 空白內容
+ return (
+
+
+ {locale.COMMON.NO_RESULTS_FOUND}{' '}
+ {currentSearch &&
{currentSearch}
}
+
+
+ )
+ }
+ // 文章首頁對應路徑
+ const href = siteConfig('GITBOOK_INDEX_PAGE') + ''
+
+ const homePost = {
+ id: '-1',
+ title: siteConfig('DESCRIPTION'),
+ href: href.indexOf('/') !== 0 ? '/' + href : href
+ }
+
+ return (
+
+ {/* 當前文章 */}
+
+
+ {/* 文章列表 */}
+ {categoryFolders?.map((group, index) => (
+ toggleItem(index)} // 將切換函式傳給子元件
+ />
+ ))}
+
+ )
+}
+
+// 依分類將文章歸成資料夾
+function groupArticles(filteredNavPages) {
+ if (!filteredNavPages) {
+ return []
+ }
+ const groups = []
+ const AUTO_SORT = siteConfig('GITBOOK_AUTO_SORT', true, CONFIG)
+
+ for (let i = 0; i < filteredNavPages.length; i++) {
+ const item = filteredNavPages[i]
+ const categoryName = item?.category ? item?.category : '' // 將 category 轉成字串
+
+ let existingGroup = null
+ // 啟用自動分組排序;會把相同分類歸到同一個資料夾,忽略 Notion 中的排序
+ if (AUTO_SORT) {
+ existingGroup = groups.find(group => group.category === categoryName) // 尋找同名的最後一個群組
+ } else {
+ existingGroup = groups[groups.length - 1] // 取得最後一個群組
+ }
+
+ // 新增資料
+ if (existingGroup && existingGroup.category === categoryName) {
+ existingGroup.items.push(item)
+ } else {
+ groups.push({ category: categoryName, items: [item] })
+ }
+ }
+ return groups
+}
+
+/**
+ * 查看當前路徑需要展開的選單索引
+ * 若皆不符合則回傳 0,即預設展開第一個
+ * @param {*} categoryFolders
+ * @param {*} path
+ * @returns {number} 需展開的選單索引
+ */
+function getDefaultOpenIndexByPath(categoryFolders, path) {
+ // 找出符合條件的第一個索引
+ const index = categoryFolders.findIndex(group => {
+ return group.items.some(post => path === post.href)
+ })
+
+ // 若找到符合條件的索引則回傳
+ if (index !== -1) {
+ return index
+ }
+
+ return 0
+}
+export default NavPostList
diff --git a/gitbook/components/PageNavDrawer.js b/gitbook/components/PageNavDrawer.js
new file mode 100644
index 0000000..eada964
--- /dev/null
+++ b/gitbook/components/PageNavDrawer.js
@@ -0,0 +1,61 @@
+import { useGlobal } from '@/lib/global'
+import { useGitBookGlobal } from '@/themes/gitbook'
+import { useRouter } from 'next/router'
+import { useEffect } from 'react'
+import NavPostList from './NavPostList'
+
+/**
+ * 懸浮抽屜 頁面內導覽
+ * @param toc
+ * @param post
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const PageNavDrawer = props => {
+ const { pageNavVisible, changePageNavVisible } = useGitBookGlobal()
+ const { filteredNavPages } = props
+ const { locale } = useGlobal()
+ const router = useRouter()
+ const switchVisible = () => {
+ changePageNavVisible(!pageNavVisible)
+ }
+
+ useEffect(() => {
+ changePageNavVisible(false)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [router])
+
+ return (
+ <>
+
+ {/* 側邊選單 */}
+
+
+ {locale.COMMON.ARTICLE_LIST}
+ {
+ changePageNavVisible(false)
+ }}>
+
+ {/* 所有文章列表 */}
+
+
+
+
+
+
+ {/* 背景遮罩 */}
+
+ >
+ )
+}
+export default PageNavDrawer
diff --git a/gitbook/components/PaginationSimple.js b/gitbook/components/PaginationSimple.js
new file mode 100644
index 0000000..7e579df
--- /dev/null
+++ b/gitbook/components/PaginationSimple.js
@@ -0,0 +1,54 @@
+import SmartLink from '@/components/SmartLink'
+import { useRouter } from 'next/router'
+import { useGlobal } from '@/lib/global'
+
+/**
+ * 簡易翻頁外掛
+ * @param page 當前頁碼
+ * @param totalPage 是否有下一頁
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const PaginationSimple = ({ page, totalPage }) => {
+ const { locale } = useGlobal()
+ const router = useRouter()
+ const currentPage = +page
+ const showNext = currentPage < totalPage
+ const pagePrefix = router.asPath.replace(/\/page\/[1-9]\d*/, '').replace(/\/$/, '')
+
+ return (
+
+
+ ←{locale.PAGINATION.PREV}
+
+
+
+
+ {locale.PAGINATION.NEXT}→
+
+
+ )
+}
+
+export default PaginationSimple
diff --git a/gitbook/components/Progress.js b/gitbook/components/Progress.js
new file mode 100644
index 0000000..3ae1dcc
--- /dev/null
+++ b/gitbook/components/Progress.js
@@ -0,0 +1,44 @@
+import { useEffect, useState } from 'react'
+import { isBrowser } from '@/lib/utils'
+
+/**
+ * 頂部頁面閱讀進度條
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const Progress = ({ targetRef, showPercent = true }) => {
+ const currentRef = targetRef?.current || targetRef
+ const [percent, changePercent] = useState(0)
+ const scrollListener = () => {
+ const target = currentRef || (isBrowser && document.getElementById('posts-wrapper'))
+ if (target) {
+ const clientHeight = target.clientHeight
+ const scrollY = window.pageYOffset
+ const fullHeight = clientHeight - window.outerHeight
+ let per = parseFloat(((scrollY / fullHeight) * 100).toFixed(0))
+ if (per > 100) per = 100
+ if (per < 0) per = 0
+ changePercent(per)
+ }
+ }
+
+ useEffect(() => {
+ document.addEventListener('scroll', scrollListener)
+ return () => document.removeEventListener('scroll', scrollListener)
+ }, [])
+
+ return (
+
+
+ {showPercent && (
+
{percent}%
+ )}
+
+
+ )
+}
+
+export default Progress
diff --git a/gitbook/components/RevolverMaps.js b/gitbook/components/RevolverMaps.js
new file mode 100644
index 0000000..b994de5
--- /dev/null
+++ b/gitbook/components/RevolverMaps.js
@@ -0,0 +1,36 @@
+import { useEffect, useState } from 'react'
+
+export default function RevolverMaps () {
+ const [load, changeLoad] = useState(false)
+ useEffect(() => {
+ if (!load) {
+ initRevolverMaps()
+ changeLoad(true)
+ }
+ }, [])
+ return
+}
+
+function initRevolverMaps () {
+ if (screen.width >= 768) {
+ Promise.all([
+ loadExternalResource('https://rf.revolvermaps.com/0/0/8.js?i=5jnp1havmh9&m=0&c=ff0000&cr1=ffffff&f=arial&l=33')
+ ]).then(() => {
+ console.log('地圖載入完成')
+ })
+ }
+}
+
+// 封裝非同步載入資源的方法
+function loadExternalResource (url) {
+ return new Promise((resolve, reject) => {
+ const container = document.getElementById('revolvermaps')
+ const tag = document.createElement('script')
+ tag.src = url
+ if (tag) {
+ tag.onload = () => resolve(url)
+ tag.onerror = () => reject(url)
+ container.appendChild(tag)
+ }
+ })
+}
diff --git a/gitbook/components/SearchInput.js b/gitbook/components/SearchInput.js
new file mode 100644
index 0000000..5c2cfc9
--- /dev/null
+++ b/gitbook/components/SearchInput.js
@@ -0,0 +1,159 @@
+import { siteConfig } from '@/lib/config'
+import { deepClone } from '@/lib/utils'
+import { useGitBookGlobal } from '@/themes/gitbook'
+import { useImperativeHandle, useRef, useState } from 'react'
+import { useHotkeys } from 'react-hotkeys-hook'
+let lock = false
+
+/**
+ * 搜尋列
+ */
+const SearchInput = ({ currentSearch, cRef, className }) => {
+ const searchInputRef = useRef()
+ const { searchModal, setFilteredNavPages, allNavPages } = useGitBookGlobal()
+
+ useImperativeHandle(cRef, () => {
+ return {
+ focus: () => {
+ searchInputRef?.current?.focus()
+ }
+ }
+ })
+
+ /**
+ * 快捷鍵設定
+ */
+ useHotkeys('ctrl+k', e => {
+ searchInputRef?.current?.focus()
+ e.preventDefault()
+ handleSearch()
+ })
+
+ const handleSearch = () => {
+ // 使用 Algolia
+ if (siteConfig('ALGOLIA_APP_ID')) {
+ searchModal?.current?.openSearch()
+ }
+ let keyword = searchInputRef.current.value
+ if (keyword) {
+ keyword = keyword.trim()
+ } else {
+ setFilteredNavPages(allNavPages)
+ return
+ }
+ const filterAllNavPages = deepClone(allNavPages)
+
+ for (let i = filterAllNavPages.length - 1; i >= 0; i--) {
+ const post = filterAllNavPages[i]
+ const articleInfo = post.title + ''
+ const hit = articleInfo.toLowerCase().indexOf(keyword.toLowerCase()) > -1
+ if (!hit) {
+ // 刪除
+ filterAllNavPages.splice(i, 1)
+ }
+ }
+
+ // 更新完成
+ setFilteredNavPages(filterAllNavPages)
+ }
+
+ /**
+ * Enter 鍵
+ * @param {*} e
+ */
+ const handleKeyUp = e => {
+ // 使用 Algolia
+ if (siteConfig('ALGOLIA_APP_ID')) {
+ searchModal?.current?.openSearch()
+ return
+ }
+
+ if (e.keyCode === 13) {
+ // Enter
+ handleSearch(searchInputRef.current.value)
+ } else if (e.keyCode === 27) {
+ // ESC
+ cleanSearch()
+ }
+ }
+
+ const handleFocus = () => {
+ // 使用 Algolia
+ if (siteConfig('ALGOLIA_APP_ID')) {
+ searchModal?.current?.openSearch()
+ }
+ }
+
+ /**
+ * 清除搜尋
+ */
+ const cleanSearch = () => {
+ searchInputRef.current.value = ''
+ handleSearch()
+ setShowClean(false)
+ }
+
+ const [showClean, setShowClean] = useState(false)
+ const updateSearchKey = val => {
+ if (lock) {
+ return
+ }
+ searchInputRef.current.value = val
+ if (val) {
+ setShowClean(true)
+ } else {
+ setShowClean(false)
+ }
+ }
+
+ function lockSearchInput() {
+ lock = true
+ }
+
+ function unLockSearchInput() {
+ lock = false
+ }
+
+ return (
+
+
+
+
+
updateSearchKey(e.target.value)}
+ defaultValue={currentSearch}
+ />
+
+ Ctrl+K
+
+
+ {showClean && (
+
+
+
+ )}
+
+ )
+}
+
+export default SearchInput
diff --git a/gitbook/components/SocialButton.js b/gitbook/components/SocialButton.js
new file mode 100644
index 0000000..5bf8688
--- /dev/null
+++ b/gitbook/components/SocialButton.js
@@ -0,0 +1,188 @@
+import QrCode from '@/components/QrCode'
+import { siteConfig } from '@/lib/config'
+import { useRef, useState } from 'react'
+import { handleEmailClick } from '@/lib/plugins/mailEncrypt'
+
+/**
+ * 社群聯絡按鈕組
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const SocialButton = () => {
+ const CONTACT_GITHUB = siteConfig('CONTACT_GITHUB')
+ const CONTACT_TWITTER = siteConfig('CONTACT_TWITTER')
+ const CONTACT_TELEGRAM = siteConfig('CONTACT_TELEGRAM')
+
+ const CONTACT_LINKEDIN = siteConfig('CONTACT_LINKEDIN')
+ const CONTACT_WEIBO = siteConfig('CONTACT_WEIBO')
+ const CONTACT_INSTAGRAM = siteConfig('CONTACT_INSTAGRAM')
+ const CONTACT_EMAIL = siteConfig('CONTACT_EMAIL')
+ const ENABLE_RSS = siteConfig('ENABLE_RSS')
+ const CONTACT_BILIBILI = siteConfig('CONTACT_BILIBILI')
+ const CONTACT_YOUTUBE = siteConfig('CONTACT_YOUTUBE')
+
+ const CONTACT_XIAOHONGSHU = siteConfig('CONTACT_XIAOHONGSHU')
+ const CONTACT_ZHISHIXINGQIU = siteConfig('CONTACT_ZHISHIXINGQIU')
+ const CONTACT_WEHCHAT_PUBLIC = siteConfig('CONTACT_WEHCHAT_PUBLIC')
+ const [qrCodeShow, setQrCodeShow] = useState(false)
+
+ const openPopover = () => {
+ setQrCodeShow(true)
+ }
+ const closePopover = () => {
+ setQrCodeShow(false)
+ }
+
+ const emailIcon = useRef(null)
+
+
+ return (
+
+ )
+}
+export default SocialButton
diff --git a/gitbook/components/TagGroups.js b/gitbook/components/TagGroups.js
new file mode 100644
index 0000000..b02594e
--- /dev/null
+++ b/gitbook/components/TagGroups.js
@@ -0,0 +1,27 @@
+import TagItemMini from './TagItemMini'
+
+/**
+ * 標籤組
+ * @param tags
+ * @param currentTag
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const TagGroups = ({ tagOptions, currentTag }) => {
+ if (!tagOptions) return <>>
+ return (
+
+ )
+}
+
+export default TagGroups
diff --git a/gitbook/components/TagItemMini.js b/gitbook/components/TagItemMini.js
new file mode 100644
index 0000000..e8fde28
--- /dev/null
+++ b/gitbook/components/TagItemMini.js
@@ -0,0 +1,21 @@
+import SmartLink from '@/components/SmartLink'
+
+const TagItemMini = ({ tag, selected = false }) => {
+ return (
+
+
+ {selected && } {tag.name + (tag.count ? `(${tag.count})` : '')}
+
+
+ )
+}
+
+export default TagItemMini
diff --git a/gitbook/config.js b/gitbook/config.js
new file mode 100644
index 0000000..dfef470
--- /dev/null
+++ b/gitbook/config.js
@@ -0,0 +1,25 @@
+const CONFIG = {
+ GITBOOK_INDEX_PAGE: 'about', // 文件首頁顯示的文章,請確認此路徑包含在您的 Notion 資料庫中
+
+ GITBOOK_AUTO_SORT: process.env.NEXT_PUBLIC_GITBOOK_AUTO_SORT || true, // 是否自動依分類名稱分組排序文章;自動分組可能會打亂您在 Notion 中的文章順序
+
+ GITBOOK_LATEST_POST_RED_BADGE:
+ process.env.NEXT_PUBLIC_GITBOOK_LATEST_POST_RED_BADGE || true, // 是否替最新文章顯示紅點
+
+ // 選單
+ GITBOOK_MENU_CATEGORY: true, // 顯示分類
+ GITBOOK_BOOK_MENU_TAG: true, // 顯示標籤
+ GITBOOK_MENU_ARCHIVE: true, // 顯示歸檔
+ GITBOOK_MENU_SEARCH: true, // 顯示搜尋
+
+ // 導覽文章自動排他折疊
+ GITBOOK_EXCLUSIVE_COLLAPSE: true, // 一次只展開一個分類,其它資料夾自動關閉。
+
+ GITBOOK_FOLDER_HOVER_EXPAND: false, // 左側導覽資料夾滑鼠懸停時自動展開;若為 false 則需點擊才會展開
+
+ // Widget
+ GITBOOK_WIDGET_REVOLVER_MAPS:
+ process.env.NEXT_PUBLIC_WIDGET_REVOLVER_MAPS || 'false', // 地圖外掛
+ GITBOOK_WIDGET_TO_TOP: true // 回到頂端按鈕
+}
+export default CONFIG
diff --git a/gitbook/index.js b/gitbook/index.js
new file mode 100644
index 0000000..02e9f69
--- /dev/null
+++ b/gitbook/index.js
@@ -0,0 +1,14 @@
+export { LayoutBase, useGitBookGlobal } from './layouts/BaseLayout'
+export {
+ LayoutDashboard,
+ LayoutIndex,
+ LayoutPostList,
+ LayoutSearch
+} from './layouts/ListLayouts'
+export { LayoutArchive } from './layouts/ArchiveLayout'
+export { LayoutSlug } from './layouts/SlugLayout'
+export { Layout404 } from './layouts/NotFoundLayout'
+export { LayoutCategoryIndex, LayoutTagIndex } from './layouts/TaxonomyLayouts'
+export { LayoutSignIn, LayoutSignUp } from './layouts/AuthLayouts'
+export { default as CONFIG } from './config'
+export { default as THEME_CONFIG } from './config'
diff --git a/gitbook/layouts/ArchiveLayout.jsx b/gitbook/layouts/ArchiveLayout.jsx
new file mode 100644
index 0000000..7b3cd46
--- /dev/null
+++ b/gitbook/layouts/ArchiveLayout.jsx
@@ -0,0 +1,27 @@
+'use client'
+
+import BlogArchiveItem from '../components/BlogArchiveItem'
+
+/**
+ * 歸檔頁面
+ * 主要依靠頁面導覽
+ */
+const LayoutArchive = props => {
+ const { archivePosts } = props
+
+ return (
+ <>
+
+ {Object.keys(archivePosts)?.map(archiveTitle => (
+
+ ))}
+
+ >
+ )
+}
+
+export { LayoutArchive }
diff --git a/gitbook/layouts/AuthLayouts.jsx b/gitbook/layouts/AuthLayouts.jsx
new file mode 100644
index 0000000..b0da5b2
--- /dev/null
+++ b/gitbook/layouts/AuthLayouts.jsx
@@ -0,0 +1,54 @@
+'use client'
+
+import NotionPage from '@/components/NotionPage'
+import { SignIn, SignUp } from '@clerk/nextjs'
+
+/**
+ * 登入頁面
+ */
+const LayoutSignIn = props => {
+ const { post } = props
+ const enableClerk = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
+
+ return (
+ <>
+
+ {/* Clerk 預設表單 */}
+ {enableClerk && (
+
+
+
+ )}
+
+
+
+
+ >
+ )
+}
+
+/**
+ * 註冊頁面
+ */
+const LayoutSignUp = props => {
+ const { post } = props
+ const enableClerk = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
+
+ return (
+ <>
+
+ {/* Clerk 預設表單 */}
+ {enableClerk && (
+
+
+
+ )}
+
+
+
+
+ >
+ )
+}
+
+export { LayoutSignIn, LayoutSignUp }
diff --git a/gitbook/layouts/BaseLayout.jsx b/gitbook/layouts/BaseLayout.jsx
new file mode 100644
index 0000000..9733332
--- /dev/null
+++ b/gitbook/layouts/BaseLayout.jsx
@@ -0,0 +1,223 @@
+'use client'
+
+import { AdSlot } from '@/components/GoogleAdsense'
+import Live2D from '@/components/Live2D'
+import LoadingCover from '@/components/LoadingCover'
+import dynamic from 'next/dynamic'
+import { createContext, useContext, useEffect, useRef, useState } from 'react'
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import { useRouter } from 'next/router'
+import { getShortId } from '@/lib/utils/pageId'
+import Announcement from '../components/Announcement'
+import ArticleInfo from '../components/ArticleInfo'
+import BottomMenuBar from '../components/BottomMenuBar'
+import Catalog from '../components/Catalog'
+import Footer from '../components/Footer'
+import Header from '../components/Header'
+import InfoCard from '../components/InfoCard'
+import JumpToTopButton from '../components/JumpToTopButton'
+import NavPostList from '../components/NavPostList'
+import PageNavDrawer from '../components/PageNavDrawer'
+import RevolverMaps from '../components/RevolverMaps'
+import CONFIG from '../config'
+import { Style } from '../style'
+
+const AlgoliaSearchModal = dynamic(
+ () => import('@/components/AlgoliaSearchModal'),
+ { ssr: false }
+)
+const WWAds = dynamic(() => import('@/components/WWAds'), { ssr: false })
+
+// 主題全域變數
+const ThemeGlobalGitbook = createContext()
+const useGitBookGlobal = () => useContext(ThemeGlobalGitbook)
+
+/**
+ * 為最新文章加入紅點標記
+ */
+function getNavPagesWithLatest(allNavPages, latestPosts, post) {
+ // localStorage 儲存 id 與上次閱讀時間戳: posts_read_time = {"${post.id}":"Date()"}
+ const postReadTime = JSON.parse(
+ localStorage.getItem('post_read_time') || '{}'
+ )
+ if (post) {
+ postReadTime[getShortId(post.id)] = new Date().getTime()
+ }
+ // 更新記錄
+ localStorage.setItem('post_read_time', JSON.stringify(postReadTime))
+
+ return allNavPages?.map(item => {
+ const res = {
+ short_id: item.short_id,
+ title: item.title || '',
+ pageCoverThumbnail: item.pageCoverThumbnail || '',
+ category: item.category || null,
+ tags: item.tags || null,
+ summary: item.summary || null,
+ slug: item.slug,
+ href: item.href,
+ pageIcon: item.pageIcon || '',
+ lastEditedDate: item.lastEditedDate
+ }
+ // 屬於最新的文章通常 6 篇 &&(無閱讀紀錄 || 最近更新時間大於上次閱讀時間)
+ if (
+ latestPosts.some(post => post?.id.indexOf(item?.short_id) === 14) &&
+ (!postReadTime[item.short_id] ||
+ postReadTime[item.short_id] < new Date(item.lastEditedDate).getTime())
+ ) {
+ return { ...res, isLatest: true }
+ } else {
+ return res
+ }
+ })
+}
+
+/**
+ * 基礎佈局
+ * 左右雙欄,手機版改為頂部導覽列
+ */
+const LayoutBase = props => {
+ const {
+ children,
+ post,
+ allNavPages,
+ latestPosts,
+ slotLeft,
+ slotRight,
+ slotTop
+ } = props
+ const { fullWidth } = useGlobal()
+ const router = useRouter()
+ const [tocVisible, changeTocVisible] = useState(false)
+ const [pageNavVisible, changePageNavVisible] = useState(false)
+ const [filteredNavPages, setFilteredNavPages] = useState(allNavPages)
+
+ const searchModal = useRef(null)
+
+ useEffect(() => {
+ setFilteredNavPages(getNavPagesWithLatest(allNavPages, latestPosts, post))
+ }, [router])
+
+ const GITBOOK_LOADING_COVER = siteConfig(
+ 'GITBOOK_LOADING_COVER',
+ true,
+ CONFIG
+ )
+ return (
+
+
+
+
+
+
+ {/* 頂部導覽列 */}
+
+
+
+ {/* 左側抽屜 */}
+ {fullWidth ? null : (
+
+
+ {/* 導覽清單 */}
+
+ {/* 嵌入區塊 */}
+ {slotLeft}
+
+ {/* 文章列表 */}
+
+
+ {/* 頁尾 */}
+
+
+
+ )}
+
+ {/* 內容區域 */}
+
+
+ {slotTop}
+
+
+ {children}
+
+ {/* Google 廣告 */}
+
+
+
+
+ {/* 手機版頁尾 */}
+
+
+
+
+
+ {/* 右側資訊欄 */}
+ {fullWidth ? null : (
+
+
+
+
+
+ {/* 桌面版目錄 */}
+
+ {slotRight}
+ {router.route === '/' && (
+ <>
+
+ {siteConfig(
+ 'GITBOOK_WIDGET_REVOLVER_MAPS',
+ null,
+ CONFIG
+ ) === 'true' &&
}
+
+ >
+ )}
+ {/* 主題首頁僅顯示公告 */}
+
+
+
+
+
+
+
+ )}
+
+
+ {GITBOOK_LOADING_COVER &&
}
+
+ {/* 回頂按鈕 */}
+
+
+ {/* 手機版導覽抽屜 */}
+
+
+ {/* 手機版底部導覽列 */}
+
+
+
+ )
+}
+
+export { LayoutBase, useGitBookGlobal }
diff --git a/gitbook/layouts/ListLayouts.jsx b/gitbook/layouts/ListLayouts.jsx
new file mode 100644
index 0000000..407d95c
--- /dev/null
+++ b/gitbook/layouts/ListLayouts.jsx
@@ -0,0 +1,101 @@
+'use client'
+
+import NotionPage from '@/components/NotionPage'
+import { siteConfig } from '@/lib/config'
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/router'
+import DashboardBody from '../components/ui/dashboard/DashboardBody'
+import DashboardHeader from '../components/ui/dashboard/DashboardHeader'
+import CONFIG from '../config'
+
+/**
+ * 首頁
+ * 重新導向到指定的文章詳情頁
+ */
+const LayoutIndex = props => {
+ const router = useRouter()
+ const index = siteConfig('GITBOOK_INDEX_PAGE', 'about', CONFIG)
+ const [hasRedirected, setHasRedirected] = useState(false) // 用來追蹤是否已重新導向
+
+ useEffect(() => {
+ const tryRedirect = async () => {
+ if (!hasRedirected) {
+ // 僅在尚未導向時執行
+ setHasRedirected(true)
+
+ // 重新導向至指定文章
+ await router.push(index)
+
+ // 使用 setTimeout 檢查頁面載入狀態
+ setTimeout(() => {
+ const article = document.querySelector(
+ '#article-wrapper #notion-article'
+ )
+ if (!article) {
+ console.log('請檢查您的 Notion 資料庫中是否包含此 slug 頁面: ', index)
+
+ // 顯示錯誤訊息
+ const containerInner = document.querySelector(
+ '#theme-gitbook #container-inner'
+ )
+ const newHTML = `設定錯誤
請在您的 Notion 中新增一篇 slug 為 ${index} 的文章
`
+ containerInner?.insertAdjacentHTML('afterbegin', newHTML)
+ }
+ }, 2000)
+ }
+ }
+
+ if (index) {
+ console.log('重新導向', index)
+ tryRedirect()
+ } else {
+ console.log('未重新導向', index)
+ }
+ }, [index, hasRedirected, router])
+
+ return null
+}
+
+/**
+ * 文章列表
+ * 主要依靠頁面導覽
+ */
+const LayoutPostList = () => {
+ return <>>
+}
+
+/**
+ * 文章搜尋頁
+ * 主要依靠頁面導覽
+ */
+const LayoutSearch = () => {
+ return <>>
+}
+
+/**
+ * 儀表板
+ */
+const LayoutDashboard = props => {
+ const { post } = props
+
+ return (
+ <>
+
+
+
+ {post && (
+
+
+
+ )}
+
+
+
+ {/* 儀表板 */}
+
+
+ >
+ )
+}
+
+export { LayoutDashboard, LayoutIndex, LayoutPostList, LayoutSearch }
diff --git a/gitbook/layouts/NotFoundLayout.jsx b/gitbook/layouts/NotFoundLayout.jsx
new file mode 100644
index 0000000..613e499
--- /dev/null
+++ b/gitbook/layouts/NotFoundLayout.jsx
@@ -0,0 +1,43 @@
+'use client'
+
+import { useGlobal } from '@/lib/global'
+import { isBrowser } from '@/lib/utils'
+import { useEffect } from 'react'
+import { useRouter } from 'next/router'
+
+/**
+ * 404 頁面
+ */
+const Layout404 = () => {
+ const router = useRouter()
+ const { locale } = useGlobal()
+ useEffect(() => {
+ // 延遲 3 秒若載入失敗就返回首頁
+ setTimeout(() => {
+ const article = isBrowser && document.getElementById('article-wrapper')
+ if (!article) {
+ router.push('/').then(() => {
+ // console.log('找不到頁面', router.asPath)
+ })
+ }
+ }, 3000)
+ }, [router])
+
+ return (
+ <>
+
+
+
+
+ 404
+
+
+
{locale.NAV.PAGE_NOT_FOUND_REDIRECT}
+
+
+
+ >
+ )
+}
+
+export { Layout404 }
diff --git a/gitbook/layouts/SlugLayout.jsx b/gitbook/layouts/SlugLayout.jsx
new file mode 100644
index 0000000..e4aab37
--- /dev/null
+++ b/gitbook/layouts/SlugLayout.jsx
@@ -0,0 +1,111 @@
+'use client'
+
+import Comment from '@/components/Comment'
+import NotionIcon from '@/components/NotionIcon'
+import NotionPage from '@/components/NotionPage'
+import ShareBar from '@/components/ShareBar'
+import { siteConfig } from '@/lib/config'
+import { isBrowser } from '@/lib/utils'
+import Head from 'next/head'
+import { useEffect } from 'react'
+import { useRouter } from 'next/router'
+import CategoryItem from '../components/CategoryItem'
+import { ArticleLock } from '../components/ArticleLock'
+import ArticleAround from '../components/ArticleAround'
+import CatalogDrawerWrapper from '../components/CatalogDrawerWrapper'
+import TagItemMini from '../components/TagItemMini'
+import CONFIG from '../config'
+
+/**
+ * 文章詳情頁
+ */
+const LayoutSlug = props => {
+ const { post, prev, next, siteInfo, lock, validPassword } = props
+ const router = useRouter()
+ // 若為文件首頁文章則更新瀏覽器標題
+ const index = siteConfig('GITBOOK_INDEX_PAGE', 'about', CONFIG)
+ const basePath = router.asPath.split('?')[0]
+ const title =
+ basePath?.indexOf(index) > 0
+ ? `${post?.title} | ${siteInfo?.description}`
+ : `${post?.title} | ${siteInfo?.title}`
+
+ const waiting404 = siteConfig('POST_WAITING_TIME_FOR_404') * 1000
+ useEffect(() => {
+ // 404 處理
+ if (!post) {
+ setTimeout(() => {
+ if (isBrowser) {
+ const article = document.querySelector(
+ '#article-wrapper #notion-article'
+ )
+ if (!article) {
+ router.push('/404').then(() => {
+ console.warn('找不到頁面', router.asPath)
+ })
+ }
+ }
+ }, waiting404)
+ }
+ }, [post, router, waiting404])
+ return (
+ <>
+
+ {title}
+
+
+ {/* 文章加鎖 */}
+ {lock && }
+
+ {!lock && (
+
+ {/* 標題 */}
+
+ {siteConfig('POST_TITLE_ICON') && (
+
+ )}
+ {post?.title}
+
+
+ {/* Notion 文章主體 */}
+ {post && (
+
+
+
+
+
+ {/* 分享 */}
+
+ {/* 文章分類與標籤資訊 */}
+
+ {siteConfig('POST_DETAIL_CATEGORY') && post?.category && (
+
+ )}
+
+ {siteConfig('POST_DETAIL_TAG') &&
+ post?.tagItems?.map(tag => (
+
+ ))}
+
+
+
+ {post?.type === 'Post' && (
+
+ )}
+
+ {/*
+ */}
+
+
+
+ )}
+
+ {/* 文章目錄 */}
+
+
+ )}
+ >
+ )
+}
+
+export { LayoutSlug }
diff --git a/gitbook/layouts/TaxonomyLayouts.jsx b/gitbook/layouts/TaxonomyLayouts.jsx
new file mode 100644
index 0000000..92d6125
--- /dev/null
+++ b/gitbook/layouts/TaxonomyLayouts.jsx
@@ -0,0 +1,72 @@
+'use client'
+
+import SmartLink from '@/components/SmartLink'
+import { useGlobal } from '@/lib/global'
+import TagItemMini from '../components/TagItemMini'
+
+/**
+ * 分類列表
+ */
+const LayoutCategoryIndex = props => {
+ const { categoryOptions } = props
+ const { locale } = useGlobal()
+ return (
+ <>
+
+
+
+ {locale.COMMON.CATEGORY}:
+
+
+ {categoryOptions?.map(category => {
+ return (
+
+
+
+ {category.name}({category.count})
+
+
+ )
+ })}
+
+
+ >
+ )
+}
+
+/**
+ * 標籤列表
+ */
+const LayoutTagIndex = props => {
+ const { tagOptions } = props
+ const { locale } = useGlobal()
+
+ return (
+ <>
+
+
+
+ {locale.COMMON.TAGS}:
+
+
+
+ >
+ )
+}
+
+export { LayoutCategoryIndex, LayoutTagIndex }
diff --git a/gitbook/style.js b/gitbook/style.js
new file mode 100644
index 0000000..96b26b3
--- /dev/null
+++ b/gitbook/style.js
@@ -0,0 +1,22 @@
+/* eslint-disable react/no-unknown-property */
+/**
+ * 此處樣式僅對當前主題生效
+ * 此處不支援 tailwindCSS 的 @apply 語法
+ * @returns
+ */
+const Style = () => {
+ return (
+
+ )
+}
+
+export { Style }