From 49dab41935ad5b28129004cf7beee1fb62aa9045 Mon Sep 17 00:00:00 2001 From: MH Hung Date: Wed, 17 Sep 2025 13:06:47 +0800 Subject: [PATCH] Add GitBook theme structure with Traditional Chinese docs --- gitbook/README.md | 82 ++++++++ gitbook/components/Announcement.js | 21 ++ gitbook/components/ArticleAround.js | 41 ++++ gitbook/components/ArticleInfo.js | 17 ++ gitbook/components/ArticleLock.js | 67 +++++++ gitbook/components/BlogArchiveItem.js | 36 ++++ gitbook/components/BlogPostCard.js | 33 +++ gitbook/components/BottomMenuBar.js | 50 +++++ gitbook/components/Card.js | 9 + gitbook/components/Catalog.js | 104 ++++++++++ gitbook/components/CatalogDrawerWrapper.js | 66 ++++++ gitbook/components/CategoryGroup.js | 19 ++ gitbook/components/CategoryItem.js | 18 ++ gitbook/components/Footer.js | 66 ++++++ gitbook/components/Header.js | 134 +++++++++++++ gitbook/components/InfoCard.js | 20 ++ gitbook/components/JumpToTopButton.js | 28 +++ gitbook/components/LeftMenuBar.js | 15 ++ gitbook/components/LogoBar.js | 29 +++ gitbook/components/MenuBarMobile.js | 54 +++++ gitbook/components/MenuItemCollapse.js | 97 +++++++++ gitbook/components/MenuItemDrop.js | 73 +++++++ gitbook/components/MenuItemMobileNormal.js | 29 +++ gitbook/components/MenuItemPCNormal.js | 30 +++ gitbook/components/NavPostItem.js | 90 +++++++++ gitbook/components/NavPostList.js | 165 +++++++++++++++ gitbook/components/PageNavDrawer.js | 61 ++++++ gitbook/components/PaginationSimple.js | 54 +++++ gitbook/components/Progress.js | 44 ++++ gitbook/components/RevolverMaps.js | 36 ++++ gitbook/components/SearchInput.js | 159 +++++++++++++++ gitbook/components/SocialButton.js | 188 +++++++++++++++++ gitbook/components/TagGroups.js | 27 +++ gitbook/components/TagItemMini.js | 21 ++ gitbook/config.js | 25 +++ gitbook/index.js | 14 ++ gitbook/layouts/ArchiveLayout.jsx | 27 +++ gitbook/layouts/AuthLayouts.jsx | 54 +++++ gitbook/layouts/BaseLayout.jsx | 223 +++++++++++++++++++++ gitbook/layouts/ListLayouts.jsx | 101 ++++++++++ gitbook/layouts/NotFoundLayout.jsx | 43 ++++ gitbook/layouts/SlugLayout.jsx | 111 ++++++++++ gitbook/layouts/TaxonomyLayouts.jsx | 72 +++++++ gitbook/style.js | 22 ++ 44 files changed, 2675 insertions(+) create mode 100644 gitbook/README.md create mode 100755 gitbook/components/Announcement.js create mode 100644 gitbook/components/ArticleAround.js create mode 100644 gitbook/components/ArticleInfo.js create mode 100644 gitbook/components/ArticleLock.js create mode 100644 gitbook/components/BlogArchiveItem.js create mode 100644 gitbook/components/BlogPostCard.js create mode 100644 gitbook/components/BottomMenuBar.js create mode 100644 gitbook/components/Card.js create mode 100644 gitbook/components/Catalog.js create mode 100644 gitbook/components/CatalogDrawerWrapper.js create mode 100644 gitbook/components/CategoryGroup.js create mode 100644 gitbook/components/CategoryItem.js create mode 100644 gitbook/components/Footer.js create mode 100644 gitbook/components/Header.js create mode 100644 gitbook/components/InfoCard.js create mode 100644 gitbook/components/JumpToTopButton.js create mode 100644 gitbook/components/LeftMenuBar.js create mode 100644 gitbook/components/LogoBar.js create mode 100644 gitbook/components/MenuBarMobile.js create mode 100644 gitbook/components/MenuItemCollapse.js create mode 100644 gitbook/components/MenuItemDrop.js create mode 100644 gitbook/components/MenuItemMobileNormal.js create mode 100644 gitbook/components/MenuItemPCNormal.js create mode 100644 gitbook/components/NavPostItem.js create mode 100644 gitbook/components/NavPostList.js create mode 100644 gitbook/components/PageNavDrawer.js create mode 100644 gitbook/components/PaginationSimple.js create mode 100644 gitbook/components/Progress.js create mode 100644 gitbook/components/RevolverMaps.js create mode 100644 gitbook/components/SearchInput.js create mode 100644 gitbook/components/SocialButton.js create mode 100644 gitbook/components/TagGroups.js create mode 100644 gitbook/components/TagItemMini.js create mode 100644 gitbook/config.js create mode 100644 gitbook/index.js create mode 100644 gitbook/layouts/ArchiveLayout.jsx create mode 100644 gitbook/layouts/AuthLayouts.jsx create mode 100644 gitbook/layouts/BaseLayout.jsx create mode 100644 gitbook/layouts/ListLayouts.jsx create mode 100644 gitbook/layouts/NotFoundLayout.jsx create mode 100644 gitbook/layouts/SlugLayout.jsx create mode 100644 gitbook/layouts/TaxonomyLayouts.jsx create mode 100644 gitbook/style.js 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 ( +
+
+ {archiveTitle} +
+
    + {archivePosts[archiveTitle]?.map(post => { + return ( +
  • +
    + {post.date?.start_date}{' '} +   + + {post.title} + +
    +
  • + ) + })} +
+
+ ) +} 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} +
+ {children} +
+
+} +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 && ( + +
+
+ {link.name} +
+ + )} + + {hasSubMenu && ( +
+
+
+ {link.name} +
+
+ +
+
+ )} +
+ + {/* 摺疊子選單 */} + {hasSubMenu && ( + + {link?.subMenus?.map((sLink, index) => { + return ( +
+ +
+
+ {sLink.title} +
+ +
+ ) + })} + + )} + + ) +} 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.name}
    +
    + {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.name}
    +
    + {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 ( +
    + + +
    + ) +} + +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 ( +
    +
    + {CONTACT_GITHUB && ( + + + + )} + {CONTACT_TWITTER && ( + + + + )} + {CONTACT_TELEGRAM && ( + + + + )} + {CONTACT_LINKEDIN && ( + + + + )} + {CONTACT_WEIBO && ( + + + + )} + {CONTACT_INSTAGRAM && ( + + + + )} + {CONTACT_EMAIL && ( + handleEmailClick(e, emailIcon, CONTACT_EMAIL)} + title='email' + className='cursor-pointer' + ref={emailIcon}> + + + )} + {ENABLE_RSS && ( + + + + )} + {CONTACT_BILIBILI && ( + + + + )} + {CONTACT_YOUTUBE && ( + + + + )} + {CONTACT_XIAOHONGSHU && ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + 小紅書 + + )} + {CONTACT_ZHISHIXINGQIU && ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + 知識星球{' '} + + )} + {CONTACT_WEHCHAT_PUBLIC && ( + + )} +
    +
    + ) +} +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 ( +
    +
    標籤
    +
    + { + tagOptions?.map(tag => { + const selected = tag.name === currentTag + 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 ( + + + ) +} + +export { Style }