diff --git a/blog.config.js b/blog.config.js
index a8a972a7..dc64824e 100644
--- a/blog.config.js
+++ b/blog.config.js
@@ -68,4 +68,4 @@ const BLOG = {
UUID_REDIRECT: process.env.UUID_REDIRECT || false
}
-module.exports = BLOG
+module.exports = BLOG
\ No newline at end of file
diff --git a/lib/db/getSiteData.js b/lib/db/getSiteData.js
index c9e3a9d4..95aec436 100755
--- a/lib/db/getSiteData.js
+++ b/lib/db/getSiteData.js
@@ -840,4 +840,4 @@ export function getNavPages({ allPages }) {
publishDate: item.publishDate,
ext: item.ext || {}
}))
-}
+}
\ No newline at end of file
diff --git a/public/images/themes-preview/typography.png b/public/images/themes-preview/typography.png
new file mode 100644
index 00000000..c70f9f8a
Binary files /dev/null and b/public/images/themes-preview/typography.png differ
diff --git a/themes/typography/components/ArticleAround.js b/themes/typography/components/ArticleAround.js
new file mode 100644
index 00000000..b4cee232
--- /dev/null
+++ b/themes/typography/components/ArticleAround.js
@@ -0,0 +1,32 @@
+import Link from 'next/link'
+
+/**
+ * 上一篇,下一篇文章
+ * @param {prev,next} param0
+ * @returns
+ */
+export default function ArticleAround({ prev, next }) {
+ if (!prev || !next) {
+ return <>>
+ }
+ return (
+
+ {prev &&
+
+ {prev.title}
+
+ }
+ {next &&
+ {next.title}
+
+
+ }
+
+ )
+}
diff --git a/themes/typography/components/ArticleInfo.js b/themes/typography/components/ArticleInfo.js
new file mode 100644
index 00000000..ed1d39a9
--- /dev/null
+++ b/themes/typography/components/ArticleInfo.js
@@ -0,0 +1,65 @@
+import Link from 'next/link'
+import { useGlobal } from '@/lib/global'
+import CONFIG from '../config'
+import { siteConfig } from '@/lib/config'
+import { formatDateFmt } from '@/lib/utils/formatDate'
+import NotionIcon from '@/components/NotionIcon'
+
+/**
+ * 文章描述
+ * @param {*} props
+ * @returns
+ */
+export default function ArticleInfo(props) {
+ const { post } = props
+
+ const { locale } = useGlobal()
+
+ return (
+
+
+ {siteConfig('POST_TITLE_ICON') && }
+ {post?.title}
+
+
+
+ {post?.type !== 'Page' && (
+
+
+
+ 发布于
+
+ {post.date?.start_date || post.createdTime}
+
+
+
+
+
+ {/* {post.category && (
+
+ {' '}
+
+
+ {post.category}
+
+
+ )} */}
+ {post?.tags &&
+ post?.tags?.length > 0 &&
+ post?.tags.map(t => (
+
+ #{t}
+
+ ))}
+
+
+ )}
+
+
+ )
+}
diff --git a/themes/typography/components/ArticleLock.js b/themes/typography/components/ArticleLock.js
new file mode 100644
index 00000000..d9f6e978
--- /dev/null
+++ b/themes/typography/components/ArticleLock.js
@@ -0,0 +1,52 @@
+import { useGlobal } from '@/lib/global'
+import { useEffect, useRef } from 'react'
+
+/**
+ * 加密文章校验组件
+ * @param {password, validPassword} props
+ * @param password 正确的密码
+ * @param validPassword(bool) 回调函数,校验正确回调入参为true
+ * @returns
+ */
+export default function ArticleLock (props) {
+ const { validPassword } = props
+ const { locale } = useGlobal()
+
+ 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}
`
+ }
+ }
+ }
+ const passwordInputRef = useRef(null)
+ useEffect(() => {
+ // 选中密码输入框并将其聚焦
+ passwordInputRef.current.focus()
+ }, [])
+
+ 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 font-light leading-10 text-black dark:bg-gray-500 bg-gray-50'
+ >
+
+ {locale.COMMON.SUBMIT}
+
+
+
+
+
+
+}
diff --git a/themes/typography/components/BlogArchiveItem.js b/themes/typography/components/BlogArchiveItem.js
new file mode 100644
index 00000000..4f849906
--- /dev/null
+++ b/themes/typography/components/BlogArchiveItem.js
@@ -0,0 +1,36 @@
+import Link from 'next/link'
+
+/**
+ * 归档分组文章
+ * @param {*} param0
+ * @returns
+ */
+export default function BlogArchiveItem({ archiveTitle, archivePosts }) {
+ return (
+
+
+ {archiveTitle}
+
+
+
+
+ )
+}
diff --git a/themes/typography/components/BlogItem.js b/themes/typography/components/BlogItem.js
new file mode 100644
index 00000000..aed75761
--- /dev/null
+++ b/themes/typography/components/BlogItem.js
@@ -0,0 +1,101 @@
+import LazyImage from '@/components/LazyImage'
+import NotionIcon from '@/components/NotionIcon'
+import NotionPage from '@/components/NotionPage'
+import TwikooCommentCount from '@/components/TwikooCommentCount'
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import { formatDateFmt } from '@/lib/utils/formatDate'
+import Link from 'next/link'
+import CONFIG from '../config'
+
+export const BlogItem = props => {
+ const { post } = props
+ const { NOTION_CONFIG } = useGlobal()
+ const showPageCover = siteConfig('TYPOGRAPHY_POST_COVER_ENABLE', false, CONFIG)
+ const showPreview =
+ siteConfig('POST_LIST_PREVIEW', false, NOTION_CONFIG) && post.blockMap
+ return (
+
+ {/* 文章标题 */}
+
+
+
+ {/* 图片封面 */}
+ {showPageCover && (
+
+
+
+
+
+ )}
+
+
+
+
+
+ {siteConfig('POST_TITLE_ICON') && (
+
+ )}
+ {post.title}
+
+
+
+ {/* 文章信息 */}
+
+
+
+ 发布于
+
+ {post.date?.start_date || post.createdTime}
+
+
+
+
+
+ {/* {post.category && (
+
+ {' '}
+
+
+ {post.category}
+
+
+ )} */}
+ {post?.tags &&
+ post?.tags?.length > 0 &&
+ post?.tags.map(t => (
+
+ #{t}
+
+ ))}
+
+
+
+
+ {!showPreview && (
+ <>
+ {post.summary}
+ >
+ )}
+ {showPreview && post?.blockMap && (
+
+
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/themes/typography/components/BlogListPage.js b/themes/typography/components/BlogListPage.js
new file mode 100644
index 00000000..d7def141
--- /dev/null
+++ b/themes/typography/components/BlogListPage.js
@@ -0,0 +1,74 @@
+import { AdSlot } from '@/components/GoogleAdsense'
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import CONFIG from '../config'
+import { BlogItem } from './BlogItem'
+
+/**
+ * 博客列表
+ * @param {*} props
+ * @returns
+ */
+export default function BlogListPage(props) {
+ const { page = 1, posts, postCount } = props
+ const router = useRouter()
+ const { NOTION_CONFIG } = useGlobal()
+ const POSTS_PER_PAGE = siteConfig('POSTS_PER_PAGE', null, NOTION_CONFIG)
+ const totalPage = Math.ceil(postCount / POSTS_PER_PAGE)
+ const currentPage = +page
+
+ // 博客列表嵌入广告
+ const TYPOGRAPHY_POST_AD_ENABLE = siteConfig(
+ 'TYPOGRAPHY_POST_AD_ENABLE',
+ false,
+ CONFIG
+ )
+
+ const showPrev = currentPage > 1
+ const showNext = page < totalPage
+ const pagePrefix = router.asPath
+ .split('?')[0]
+ .replace(/\/page\/[1-9]\d*/, '')
+ .replace(/\/$/, '')
+ .replace('.html', '')
+
+ return (
+
+
+ {posts?.map((p, index) => (
+
+ {TYPOGRAPHY_POST_AD_ENABLE && (index + 1) % 3 === 0 && (
+
+ )}
+ {TYPOGRAPHY_POST_AD_ENABLE && index + 1 === 4 &&
}
+
+
+ ))}
+
+
+
+
+ NEWER POSTS
+
+
+ OLDER POSTS
+
+
+
+ )
+}
diff --git a/themes/typography/components/BlogListScroll.js b/themes/typography/components/BlogListScroll.js
new file mode 100644
index 00000000..2252a995
--- /dev/null
+++ b/themes/typography/components/BlogListScroll.js
@@ -0,0 +1,70 @@
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import throttle from 'lodash.throttle'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { BlogItem } from './BlogItem'
+
+/**
+ * 滚动博客列表
+ * @param {*} props
+ * @returns
+ */
+export default function BlogListScroll(props) {
+ const { posts } = props
+ const { locale, NOTION_CONFIG } = useGlobal()
+ const [page, updatePage] = useState(1)
+ const POSTS_PER_PAGE = siteConfig('POSTS_PER_PAGE', null, NOTION_CONFIG)
+ let hasMore = false
+ const postsToShow = posts
+ ? Object.assign(posts).slice(0, POSTS_PER_PAGE * page)
+ : []
+
+ if (posts) {
+ const totalCount = posts.length
+ hasMore = page * POSTS_PER_PAGE < totalCount
+ }
+ const handleGetMore = () => {
+ if (!hasMore) return
+ updatePage(page + 1)
+ }
+
+ const targetRef = useRef(null)
+
+ // 监听滚动自动分页加载
+ const scrollTrigger = useCallback(
+ throttle(() => {
+ const scrollS = window.scrollY + window.outerHeight
+ const clientHeight = targetRef
+ ? targetRef.current
+ ? targetRef.current.clientHeight
+ : 0
+ : 0
+ if (scrollS > clientHeight + 100) {
+ handleGetMore()
+ }
+ }, 500)
+ )
+
+ useEffect(() => {
+ window.addEventListener('scroll', scrollTrigger)
+
+ return () => {
+ window.removeEventListener('scroll', scrollTrigger)
+ }
+ })
+
+ return (
+
+ {postsToShow.map(p => (
+
+ ))}
+
+
+ {' '}
+ {hasMore ? locale.COMMON.MORE : `${locale.COMMON.NO_MORE} 😰`}{' '}
+
+
+ )
+}
diff --git a/themes/typography/components/BlogPostBar.js b/themes/typography/components/BlogPostBar.js
new file mode 100644
index 00000000..9efac113
--- /dev/null
+++ b/themes/typography/components/BlogPostBar.js
@@ -0,0 +1,29 @@
+import { useGlobal } from '@/lib/global'
+
+/**
+ * 文章列表上方嵌入
+ * @param {*} props
+ * @returns
+ */
+export default function BlogPostBar(props) {
+ const { tag, category } = props
+ const { locale } = useGlobal()
+
+ if (tag) {
+ return (
+
+
+ {locale.COMMON.TAGS}: {tag}
+
+ )
+ } else if (category) {
+ return (
+
+
+ {locale.COMMON.CATEGORY}: {category}
+
+ )
+ } else {
+ return <>>
+ }
+}
diff --git a/themes/typography/components/Catalog.js b/themes/typography/components/Catalog.js
new file mode 100644
index 00000000..e92e586a
--- /dev/null
+++ b/themes/typography/components/Catalog.js
@@ -0,0 +1,136 @@
+import { useGlobal } from '@/lib/global'
+import throttle from 'lodash.throttle'
+import { uuidToId } from 'notion-utils'
+import { useEffect, useRef, useState } from 'react'
+
+/**
+ * 目录导航组件
+ * @param toc
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const Catalog = ({ post }) => {
+ const { locale } = useGlobal()
+ // 目录自动滚动
+ const tRef = useRef(null)
+ // 同步选中目录事件
+ const [activeSection, setActiveSection] = useState(null)
+
+ // 监听滚动事件
+ useEffect(() => {
+ // 如果没有文章或目录,不执行任何操作
+ if (!post || !post?.toc || post?.toc?.length < 1) {
+ return
+ }
+
+ const throttleMs = 100 // 降低节流时间提高响应速度
+
+ const actionSectionScrollSpy = throttle(() => {
+ const sections = document.getElementsByClassName('notion-h')
+ if (!sections || sections.length === 0) return
+
+ let prevBBox = null
+ let currentSectionId = null
+
+ // 先检查当前视口中的所有标题
+ for (let i = 0; i < sections.length; ++i) {
+ const section = sections[i]
+ if (!section || !(section instanceof Element)) continue
+
+ const bbox = section.getBoundingClientRect()
+ const offset = 100 // 固定偏移量,避免计算不稳定
+
+ // 如果标题在视口上方或接近顶部,认为是当前标题
+ if (bbox.top - offset < 0) {
+ currentSectionId = section.getAttribute('data-id')
+ prevBBox = bbox
+ } else {
+ // 找到第一个在视口下方的标题就停止
+ break
+ }
+ }
+
+ // 如果没找到任何标题在视口上方,使用第一个标题
+ if (!currentSectionId && sections.length > 0) {
+ currentSectionId = sections[0].getAttribute('data-id')
+ }
+
+ // 只有当 ID 变化时才更新状态,减少不必要的渲染
+ if (currentSectionId !== activeSection) {
+ setActiveSection(currentSectionId)
+
+ // 查找目录中对应的索引并滚动
+ const index = post?.toc?.findIndex(
+ obj => uuidToId(obj.id) === currentSectionId
+ )
+
+ if (index !== -1 && tRef?.current) {
+ tRef.current.scrollTo({ top: 28 * index, behavior: 'smooth' })
+ }
+ }
+ }, throttleMs)
+
+ const content = document.querySelector('#container-inner')
+ if (!content) return // 防止 content 不存在
+
+ // 添加滚动和内容变化的监听
+ content.addEventListener('scroll', actionSectionScrollSpy)
+
+ // 初始执行一次
+ setTimeout(() => {
+ actionSectionScrollSpy()
+ }, 300) // 延迟执行确保 DOM 已完全加载
+
+ return () => {
+ content?.removeEventListener('scroll', actionSectionScrollSpy)
+ }
+ }, [post])
+
+ // 无目录就直接返回空
+ if (!post || !post?.toc || post?.toc?.length < 1) {
+ return <>>
+ }
+
+ return (
+
+
+
+ {locale.COMMON.TABLE_OF_CONTENTS}
+
+
+
+
+
+
+ )
+}
+
+export default Catalog
diff --git a/themes/typography/components/ExampleRecentComments.js b/themes/typography/components/ExampleRecentComments.js
new file mode 100644
index 00000000..93cde585
--- /dev/null
+++ b/themes/typography/components/ExampleRecentComments.js
@@ -0,0 +1,35 @@
+import Link from 'next/link'
+import { RecentComments } from '@waline/client'
+import { useEffect, useState } from 'react'
+import { siteConfig } from '@/lib/config'
+
+/**
+ * @see https://waline.js.org/guide/get-started.html
+ * @param {*} props
+ * @returns
+ */
+const ExampleRecentComments = (props) => {
+ const [comments, updateComments] = useState([])
+ const [onLoading, changeLoading] = useState(true)
+ useEffect(() => {
+ RecentComments({
+ serverURL: siteConfig('COMMENT_WALINE_SERVER_URL'),
+ count: 5
+ }).then(({ comments }) => {
+ changeLoading(false)
+ updateComments(comments)
+ })
+ }, [])
+
+ return <>
+ {onLoading && Loading...
}
+ {!onLoading && comments && comments.length === 0 && No Comments
}
+ {!onLoading && comments && comments.length > 0 && comments.map((comment) => )}
+
+ >
+}
+
+export default ExampleRecentComments
diff --git a/themes/typography/components/Footer.js b/themes/typography/components/Footer.js
new file mode 100644
index 00000000..8d03d7f2
--- /dev/null
+++ b/themes/typography/components/Footer.js
@@ -0,0 +1,29 @@
+import { BeiAnGongAn } from '@/components/BeiAnGongAn'
+import DarkModeButton from '@/components/DarkModeButton'
+import { siteConfig } from '@/lib/config'
+
+/**
+ * 页脚
+ * @param {*} props
+ * @returns
+ */
+export default function Footer(props) {
+ const d = new Date()
+ const currentYear = d.getFullYear()
+ const since = siteConfig('SINCE')
+ const copyrightDate =
+ parseInt(since) < currentYear ? since + '-' + currentYear : currentYear
+
+ return (
+
+ )
+}
diff --git a/themes/typography/components/JumpToTopButton.js b/themes/typography/components/JumpToTopButton.js
new file mode 100644
index 00000000..358c9372
--- /dev/null
+++ b/themes/typography/components/JumpToTopButton.js
@@ -0,0 +1,35 @@
+import { useGlobal } from '@/lib/global'
+import { useEffect, useState } from 'react'
+
+/**
+ * 跳转到网页顶部
+ * 当屏幕下滑500像素后会出现该控件
+ * @param targetRef 关联高度的目标html标签
+ * @param showPercent 是否显示百分比
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const JumpToTopButton = () => {
+ const { locale } = useGlobal()
+ const [show, switchShow] = useState(false)
+ const scrollListener = () => {
+ const scrollY = window.pageYOffset
+ const shouldShow = scrollY > 200
+ if (shouldShow !== show) {
+ switchShow(shouldShow)
+ }
+ }
+
+ useEffect(() => {
+ document.addEventListener('scroll', scrollListener)
+ return () => document.removeEventListener('scroll', scrollListener)
+ }, [show])
+
+ return window.scrollTo({ top: 0, behavior: 'smooth' })}
+ >
+
+}
+
+export default JumpToTopButton
diff --git a/themes/typography/components/MenuItemCollapse.js b/themes/typography/components/MenuItemCollapse.js
new file mode 100644
index 00000000..50174dc3
--- /dev/null
+++ b/themes/typography/components/MenuItemCollapse.js
@@ -0,0 +1,92 @@
+import Collapse from '@/components/Collapse'
+import Link from 'next/link'
+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 toggleShow = () => {
+ changeShow(!show)
+ }
+
+ const toggleOpenSubMenu = () => {
+ changeIsOpen(!isOpen)
+ }
+
+ if (!link || !link.show) {
+ return null
+ }
+
+ return (
+ <>
+
+ {!hasSubMenu && (
+
+
+ {link?.icon && (
+
+
+
+ )}
+ {link?.name}
+
+
+ )}
+ {hasSubMenu && (
+
+
+ {link?.icon && (
+
+
+
+ )}
+ {link?.name}
+
+
+
+ )}
+
+
+ {/* 折叠子菜单 */}
+ {hasSubMenu && (
+
+ {link.subMenus.map((sLink, index) => {
+ return (
+
+
+
+ {sLink?.icon && (
+
+
+
+ )}
+ {sLink.title}
+
+
+
+ )
+ })}
+
+ )}
+ >
+ )
+}
diff --git a/themes/typography/components/MenuItemDrop.js b/themes/typography/components/MenuItemDrop.js
new file mode 100644
index 00000000..a5effb13
--- /dev/null
+++ b/themes/typography/components/MenuItemDrop.js
@@ -0,0 +1,78 @@
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { useState } from 'react'
+
+export const MenuItemDrop = ({ link }) => {
+ const hasSubMenu = link?.subMenus?.length > 0
+ const [show, changeShow] = useState(false)
+ const router = useRouter()
+
+
+
+ if (!link || !link.show) {
+ return null
+ }
+ const selected = router.pathname === link.href || router.asPath === link.href
+
+
+ return (
+
+ {!hasSubMenu && (
+
+ {link?.name}
+
+ )
+ }
+
+
+ {hasSubMenu && (
+ <>
+
changeShow(true)}
+ onMouseOut={() => changeShow(false)}
+ className={
+ 'relative ' +
+ (selected
+ ? 'bg-green-600 text-white hover:text-white'
+ : 'hover:text-green-600')
+ }>
+
+
+ {link?.icon && } {link?.name}
+
+ {hasSubMenu && (
+
+ )}
+
+
+ {/* 子菜單 */}
+
+ {link?.subMenus?.map((sLink, index) => {
+ return (
+ -
+
+
+ {link?.icon && }
+ {sLink.title}
+
+
+
+ )
+ })}
+
+
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/themes/typography/components/MenuList.js b/themes/typography/components/MenuList.js
new file mode 100644
index 00000000..8eb458d7
--- /dev/null
+++ b/themes/typography/components/MenuList.js
@@ -0,0 +1,83 @@
+import Collapse from '@/components/Collapse'
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import { useRouter } from 'next/router'
+import { useEffect, useRef, useState } from 'react'
+import CONFIG from '../config'
+import { MenuItemCollapse } from './MenuItemCollapse'
+import { MenuItemDrop } from './MenuItemDrop'
+
+/**
+ * 菜单导航
+ * @param {*} props
+ * @returns
+ */
+export const MenuList = ({ customNav, customMenu }) => {
+ const { locale } = useGlobal()
+ const [isOpen, changeIsOpen] = useState(false)
+ const toggleIsOpen = () => {
+ changeIsOpen(!isOpen)
+ }
+ const closeMenu = e => {
+ changeIsOpen(false)
+ }
+ const router = useRouter()
+ const collapseRef = useRef(null)
+
+ useEffect(() => {
+ router.events.on('routeChangeStart', closeMenu)
+ })
+
+ let links = [
+ {
+ icon: 'fas fa-archive',
+ name: locale.NAV.ARCHIVE,
+ href: '/archive',
+ show: siteConfig('TYPOGRAPHY_MENU_ARCHIVE', null, CONFIG)
+ },
+ {
+ icon: 'fas fa-folder',
+ name: locale.COMMON.CATEGORY,
+ href: '/category',
+ show: siteConfig('TYPOGRAPHY_MENU_CATEGORY', null, CONFIG)
+ },
+ {
+ icon: 'fas fa-tag',
+ name: locale.COMMON.TAGS,
+ href: '/tag',
+ show: siteConfig('TYPOGRAPHY_MENU_TAG', 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/themes/typography/components/NavBar.js b/themes/typography/components/NavBar.js
new file mode 100644
index 00000000..53ab0b8c
--- /dev/null
+++ b/themes/typography/components/NavBar.js
@@ -0,0 +1,37 @@
+import { siteConfig } from '@/lib/config'
+import { useRouter } from 'next/router'
+import { useState } from 'react'
+import { useSimpleGlobal } from '..'
+import { MenuList } from './MenuList'
+import SocialButton from './SocialButton'
+import Link from 'next/link'
+
+/**
+ * 菜单导航
+ * @param {*} props
+ * @returns
+ */
+export default function NavBar(props) {
+ return (
+
+ )
+}
diff --git a/themes/typography/components/RecommendPosts.js b/themes/typography/components/RecommendPosts.js
new file mode 100644
index 00000000..6d86f0b4
--- /dev/null
+++ b/themes/typography/components/RecommendPosts.js
@@ -0,0 +1,32 @@
+import Link from 'next/link'
+import { useGlobal } from '@/lib/global'
+import CONFIG from '../config'
+import { siteConfig } from '@/lib/config'
+
+/**
+ * 展示文章推荐
+ */
+const RecommendPosts = ({ recommendPosts }) => {
+ const { locale } = useGlobal()
+ if (!siteConfig('TYPOGRAPHY_ARTICLE_RECOMMEND_POSTS', null, CONFIG) || !recommendPosts || recommendPosts.length < 1) {
+ return <>>
+ }
+
+ return (
+
+
{locale.COMMON.RELATE_POSTS} :
+
+ {recommendPosts.map(post => (
+ -
+
+
+ {post.title}
+
+
+
+ ))}
+
+
+ )
+}
+export default RecommendPosts
diff --git a/themes/typography/components/SocialButton.js b/themes/typography/components/SocialButton.js
new file mode 100644
index 00000000..ced5c850
--- /dev/null
+++ b/themes/typography/components/SocialButton.js
@@ -0,0 +1,115 @@
+import { siteConfig } from '@/lib/config'
+
+/**
+ * 社交联系方式按钮组
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const SocialButton = () => {
+ return (
+
+
+ {siteConfig('CONTACT_GITHUB') && (
+
+
+
+ )}
+ {siteConfig('CONTACT_TWITTER') && (
+
+
+
+ )}
+ {siteConfig('CONTACT_TELEGRAM') && (
+
+
+
+ )}
+ {siteConfig('CONTACT_LINKEDIN') && (
+
+
+
+ )}
+ {siteConfig('CONTACT_WEIBO') && (
+
+
+
+ )}
+ {siteConfig('CONTACT_INSTAGRAM') && (
+
+
+
+ )}
+ {siteConfig('CONTACT_EMAIL') && (
+
+
+
+ )}
+ {JSON.parse(siteConfig('ENABLE_RSS')) && (
+
+
+
+ )}
+ {siteConfig('CONTACT_BILIBILI') && (
+
+
+
+ )}
+ {siteConfig('CONTACT_YOUTUBE') && (
+
+
+
+ )}
+ {siteConfig('CONTACT_THREADS') && (
+
+
+
+ )}
+
+
+ )
+}
+export default SocialButton
diff --git a/themes/typography/components/Title.js b/themes/typography/components/Title.js
new file mode 100644
index 00000000..151d736e
--- /dev/null
+++ b/themes/typography/components/Title.js
@@ -0,0 +1,19 @@
+import { siteConfig } from '@/lib/config'
+
+/**
+ * 标题栏
+ * @param {*} props
+ * @returns
+ */
+export const Title = (props) => {
+ const { post } = props
+ const title = post?.title || siteConfig('DESCRIPTION')
+ const description = post?.description || siteConfig('AUTHOR')
+
+ return
+
{title}
+
+ {description}
+
+
+}
diff --git a/themes/typography/components/TopBar.js b/themes/typography/components/TopBar.js
new file mode 100644
index 00000000..e9224de8
--- /dev/null
+++ b/themes/typography/components/TopBar.js
@@ -0,0 +1,19 @@
+import CONFIG from '../config'
+import { siteConfig } from '@/lib/config'
+
+/**
+ * 网站顶部 提示栏
+ * @returns
+ */
+export default function TopBar (props) {
+ const content = siteConfig('SIMPLE_TOP_BAR_CONTENT', null, CONFIG)
+
+ if (content) {
+ return
+ }
+ return <>>
+}
diff --git a/themes/typography/config.js b/themes/typography/config.js
new file mode 100644
index 00000000..c496beb4
--- /dev/null
+++ b/themes/typography/config.js
@@ -0,0 +1,17 @@
+const CONFIG = {
+ // 博客標題 雙語言
+ TYPOGRAPHY_BLOG_NAME: process.env.NEXT_PUBLIC_TYPOGRAPHY_BLOG_NAME || '活字印刷',
+ TYPOGRAPHY_BLOG_NAME_EN: process.env.NEXT_PUBLIC_TYPOGRAPHY_BLOG_NAME || 'Typography',
+
+ TYPOGRAPHY_POST_AD_ENABLE: process.env.NEXT_PUBLIC_TYPOGRAPHY_POST_AD_ENABLE || false, // 文章列表是否插入广告
+
+ TYPOGRAPHY_POST_COVER_ENABLE: process.env.NEXT_PUBLIC_TYPOGRAPHY_POST_COVER_ENABLE || false, // 是否展示博客封面
+
+ TYPOGRAPHY_ARTICLE_RECOMMEND_POSTS: process.env.NEXT_PUBLIC_TYPOGRAPHY_ARTICLE_RECOMMEND_POSTS || true, // 文章详情底部显示推荐
+
+ // 菜单配置
+ TYPOGRAPHY_MENU_CATEGORY: true, // 显示分类
+ TYPOGRAPHY_MENU_TAG: true, // 显示标签
+ TYPOGRAPHY_MENU_ARCHIVE: true, // 显示归档
+}
+export default CONFIG
diff --git a/themes/typography/index.js b/themes/typography/index.js
new file mode 100644
index 00000000..5e648964
--- /dev/null
+++ b/themes/typography/index.js
@@ -0,0 +1,372 @@
+import { AdSlot } from '@/components/GoogleAdsense'
+import replaceSearchResult from '@/components/Mark'
+import NotionPage from '@/components/NotionPage'
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import { isBrowser } from '@/lib/utils'
+import dynamic from 'next/dynamic'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { createContext, useContext, useEffect, useRef } from 'react'
+import BlogPostBar from './components/BlogPostBar'
+import CONFIG from './config'
+import { Style } from './style'
+import Catalog from './components/Catalog'
+
+const AlgoliaSearchModal = dynamic(
+ () => import('@/components/AlgoliaSearchModal'),
+ { ssr: false }
+)
+
+// 主题组件
+
+const BlogArchiveItem = dynamic(() => import('./components/BlogArchiveItem'), {
+ ssr: false
+})
+const ArticleLock = dynamic(() => import('./components/ArticleLock'), {
+ ssr: false
+})
+const ArticleInfo = dynamic(() => import('./components/ArticleInfo'), {
+ ssr: false
+})
+const Comment = dynamic(() => import('@/components/Comment'), { ssr: false })
+const ArticleAround = dynamic(() => import('./components/ArticleAround'), {
+ ssr: false
+})
+const TopBar = dynamic(() => import('./components/TopBar'), { ssr: false })
+const NavBar = dynamic(() => import('./components/NavBar'), { ssr: false })
+const JumpToTopButton = dynamic(() => import('./components/JumpToTopButton'), {
+ ssr: false
+})
+const Footer = dynamic(() => import('./components/Footer'), { ssr: false })
+const WWAds = dynamic(() => import('@/components/WWAds'), { ssr: false })
+const BlogListPage = dynamic(() => import('./components/BlogListPage'), {
+ ssr: false
+})
+const RecommendPosts = dynamic(() => import('./components/RecommendPosts'), {
+ ssr: false
+})
+
+// 主题全局状态
+const ThemeGlobalSimple = createContext()
+export const useSimpleGlobal = () => useContext(ThemeGlobalSimple)
+
+/**
+ * 基础布局
+ *
+ * @param {*} props
+ * @returns
+ */
+const LayoutBase = props => {
+ const { children } = props
+ const { onLoading, fullWidth } = useGlobal()
+ // const onLoading = true
+ const searchModal = useRef(null)
+
+ return (
+
+
+
+
+ {siteConfig('SIMPLE_TOP_BAR', null, CONFIG) &&
}
+
+
+ {/* 主体 - 使用 flex 布局 */}
+ {/* 文章详情才显示 */}
+ {/* {props.post && (
+
+
+
+ )} */}
+
+ {/* 左侧内容区域 - 可滚动 */}
+
+ {/* 移动端导航 - 显示在顶部 */}
+
+
+
+ {onLoading ? (
+ // loading 时显示 spinner
+
+ ) : (
+ <>{children}>
+ )}
+
+ {/* 移动端页脚 - 显示在底部 */}
+
+
+
+
+
+
+ {/* 右侧导航和页脚 - 固定不滚动 */}
+
+
+
+
+
+
+
+
+
+
+ {/* 搜索框 */}
+
+
+
+ )
+}
+
+/**
+ * 博客首页
+ * 首页就是列表
+ * @param {*} props
+ * @returns
+ */
+const LayoutIndex = props => {
+ return
+}
+/**
+ * 博客列表
+ * @param {*} props
+ * @returns
+ */
+const LayoutPostList = props => {
+ return (
+ <>
+
+
+ >
+ )
+}
+
+/**
+ * 搜索页
+ * 也是博客列表
+ * @param {*} props
+ * @returns
+ */
+const LayoutSearch = props => {
+ const { keyword } = props
+
+ useEffect(() => {
+ if (isBrowser) {
+ replaceSearchResult({
+ doms: document.getElementById('posts-wrapper'),
+ search: keyword,
+ target: {
+ element: 'span',
+ className: 'text-red-500 border-b border-dashed'
+ }
+ })
+ }
+ }, [])
+
+ return
+}
+
+ function groupArticlesByYearArray(articles) {
+ const grouped = {};
+
+ for (const article of articles) {
+ const year = new Date(article.publishDate).getFullYear().toString();
+ if (!grouped[year]) {
+ grouped[year] = [];
+ }
+ grouped[year].push(article);
+ }
+
+ for (const year in grouped) {
+ grouped[year].sort((a, b) => b.publishDate - a.publishDate);
+ }
+
+ // 转成数组并按年份倒序
+ return Object.entries(grouped)
+ .sort(([a], [b]) => b - a)
+ .map(([year, posts]) => ({ year, posts }));
+}
+
+
+
+/**
+ * 归档页
+ * @param {*} props
+ * @returns
+ */
+const LayoutArchive = props => {
+ const { posts } = props
+ const sortPosts = groupArticlesByYearArray(posts)
+ return (
+ <>
+
+ {sortPosts.map(p => (
+
+ ))}
+
+ >
+ )
+}
+
+/**
+ * 文章详情
+ * @param {*} props
+ * @returns
+ */
+const LayoutSlug = props => {
+ const { post, lock, validPassword, prev, next, recommendPosts } = props
+ const { fullWidth } = useGlobal()
+
+ return (
+ <>
+ {lock && }
+
+ {!lock && post && (
+
+ {/* 文章信息 */}
+
+
+ {/* 广告嵌入 */}
+ {/*
*/}
+
+
+
+ {/* Notion 文章主体 */}
+ {!lock && }
+
+
+ {/* 分享 */}
+ {/*
*/}
+
+ {/* 广告嵌入 */}
+
+
+ {post?.type === 'Post' && (
+ <>
+
+
+ >
+ )}
+
+ {/* 评论区 */}
+
+
+ )}
+ >
+ )
+}
+
+/**
+ * 404
+ * @param {*} props
+ * @returns
+ */
+const Layout404 = props => {
+ const { post } = props
+ const router = useRouter()
+ 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])
+ return <>404 Not found.>
+}
+
+/**
+ * 分类列表
+ * @param {*} props
+ * @returns
+ */
+const LayoutCategoryIndex = props => {
+ const { categoryOptions } = props
+ return (
+ <>
+
+ {categoryOptions?.map(category => {
+ return (
+
+
+
+ {category.name}({category.count})
+
+
+ )
+ })}
+
+ >
+ )
+}
+
+/**
+ * 标签列表
+ * @param {*} props
+ * @returns
+ */
+const LayoutTagIndex = props => {
+ const { tagOptions } = props
+ return (
+ <>
+
+ >
+ )
+}
+
+export {
+ Layout404,
+ LayoutArchive,
+ LayoutBase,
+ LayoutCategoryIndex,
+ LayoutIndex,
+ LayoutPostList,
+ LayoutSearch,
+ LayoutSlug,
+ LayoutTagIndex,
+ CONFIG as THEME_CONFIG
+}
diff --git a/themes/typography/style.js b/themes/typography/style.js
new file mode 100644
index 00000000..a0f07085
--- /dev/null
+++ b/themes/typography/style.js
@@ -0,0 +1,93 @@
+/* eslint-disable react/no-unknown-property */
+/**
+ * 此处样式只对当前主题生效
+ * 此处不支持 tailwindCSS 的 @apply 语法
+ * @returns
+ */
+const Style = () => {
+ return (
+
+ )
+}
+
+export { Style }