diff --git a/themes/heo/components/AnalyticsCard.js b/themes/heo/components/AnalyticsCard.js
new file mode 100644
index 00000000..0ee1e1cd
--- /dev/null
+++ b/themes/heo/components/AnalyticsCard.js
@@ -0,0 +1,30 @@
+import Card from './Card'
+
+export function AnalyticsCard (props) {
+ const { postCount } = props
+ return
+
+ 统计
+
+
+
+}
diff --git a/themes/heo/components/Announcement.js b/themes/heo/components/Announcement.js
new file mode 100644
index 00000000..695c26a4
--- /dev/null
+++ b/themes/heo/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 = ({ post, className }) => {
+ const { locale } = useGlobal()
+ if (post?.blockMap) {
+ return
+
+ {locale.COMMON.ANNOUNCEMENT}
+ {post && (
+
+
)}
+
+
+ } else {
+ return <>>
+ }
+}
+export default Announcement
diff --git a/themes/heo/components/ArticleAdjacent.js b/themes/heo/components/ArticleAdjacent.js
new file mode 100644
index 00000000..21ca9e32
--- /dev/null
+++ b/themes/heo/components/ArticleAdjacent.js
@@ -0,0 +1,33 @@
+import Link from 'next/link'
+import CONFIG from '../config'
+
+/**
+ * 上一篇,下一篇文章
+ * @param {prev,next} param0
+ * @returns
+ */
+export default function ArticleAdjacent ({ prev, next }) {
+ if (!prev || !next || !CONFIG.ARTICLE_ADJACENT) {
+ return <>>
+ }
+ return (
+
+
+
+ {prev.title}
+
+
+
+ {next.title}
+
+
+
+
+ )
+}
diff --git a/themes/heo/components/ArticleCopyright.js b/themes/heo/components/ArticleCopyright.js
new file mode 100644
index 00000000..4664573c
--- /dev/null
+++ b/themes/heo/components/ArticleCopyright.js
@@ -0,0 +1,41 @@
+import BLOG from '@/blog.config'
+import { useGlobal } from '@/lib/global'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { useEffect, useState } from 'react'
+import CONFIG from '../config'
+
+export default function ArticleCopyright () {
+ if (!CONFIG.ARTICLE_COPYRIGHT) {
+ return <>>
+ }
+ const router = useRouter()
+ const [path, setPath] = useState(BLOG.LINK + router.asPath)
+ useEffect(() => {
+ setPath(window.location.href)
+ })
+
+ const { locale } = useGlobal()
+ return (
+
+
+ -
+ {locale.COMMON.AUTHOR}:
+
+ {BLOG.AUTHOR}
+
+
+ -
+ {locale.COMMON.URL}:
+
+ {path}
+
+
+ -
+ {locale.COMMON.COPYRIGHT}:
+ {locale.COMMON.COPYRIGHT_NOTICE}
+
+
+
+ );
+}
diff --git a/themes/heo/components/ArticleLock.js b/themes/heo/components/ArticleLock.js
new file mode 100644
index 00000000..7f1da728
--- /dev/null
+++ b/themes/heo/components/ArticleLock.js
@@ -0,0 +1,51 @@
+import { useGlobal } from '@/lib/global'
+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 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 bg-gray-100 dark:bg-gray-500'>
+
+
+ {locale.COMMON.SUBMIT}
+
+
+
+
+
+
+}
diff --git a/themes/heo/components/ArticleRecommend.js b/themes/heo/components/ArticleRecommend.js
new file mode 100644
index 00000000..e4943cf8
--- /dev/null
+++ b/themes/heo/components/ArticleRecommend.js
@@ -0,0 +1,65 @@
+import Link from 'next/link'
+import CONFIG from '../config'
+import BLOG from '@/blog.config'
+import { useGlobal } from '@/lib/global'
+
+/**
+ * 关联推荐文章
+ * @param {prev,next} param0
+ * @returns
+ */
+export default function ArticleRecommend({ recommendPosts, siteInfo }) {
+ const { locale } = useGlobal()
+
+ if (
+ !CONFIG.ARTICLE_RECOMMEND ||
+ !recommendPosts ||
+ recommendPosts.length === 0
+ ) {
+ return <>>
+ }
+
+ return (
+
+
+
+
+ {locale.COMMON.RELATE_POSTS}
+
+
+
+ {recommendPosts.map(post => {
+ const headerImage = post?.pageCoverThumbnail
+ ? `url("${post.pageCoverThumbnail}")`
+ : `url("${siteInfo?.pageCover}")`
+
+ return (
+ (
+
+
+
+
+
+
+ {post.date?.start_date}
+
+
{post.title}
+
+
+
+
+ )
+ )
+ })}
+
+
+ )
+}
diff --git a/themes/heo/components/BlogPostArchive.js b/themes/heo/components/BlogPostArchive.js
new file mode 100644
index 00000000..08feff4c
--- /dev/null
+++ b/themes/heo/components/BlogPostArchive.js
@@ -0,0 +1,49 @@
+import React from 'react'
+import Link from 'next/link'
+import BLOG from '@/blog.config'
+/**
+ * 博客归档列表
+ * @param posts 所有文章
+ * @param archiveTitle 归档标题
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const BlogPostArchive = ({ posts = [], archiveTitle }) => {
+ if (!posts || posts.length === 0) {
+ return <>>
+ } else {
+ return (
+
+ )
+ }
+}
+
+export default BlogPostArchive
diff --git a/themes/heo/components/BlogPostCard.js b/themes/heo/components/BlogPostCard.js
new file mode 100644
index 00000000..648ce49e
--- /dev/null
+++ b/themes/heo/components/BlogPostCard.js
@@ -0,0 +1,49 @@
+import Link from 'next/link'
+import React from 'react'
+import CONFIG from '../config'
+import { BlogPostCardInfo } from './BlogPostCardInfo'
+import BLOG from '@/blog.config'
+// import Image from 'next/image'
+
+const BlogPostCard = ({ index, post, showSummary, siteInfo }) => {
+ const showPreview = CONFIG.POST_LIST_PREVIEW && post.blockMap
+ if (post && !post.pageCoverThumbnail && CONFIG.POST_LIST_COVER_DEFAULT) {
+ post.pageCover = siteInfo?.pageCoverThumbnail
+ }
+ const showPageCover = CONFIG.POST_LIST_COVER && post?.pageCoverThumbnail && !showPreview
+ // const delay = (index % 2) * 200
+
+ return (
+
+
+
+
+ {/* 文字内容 */}
+
+
+ {/* 图片封面 */}
+ {showPageCover && (
+
+ )}
+
+
+
+
+
+ )
+}
+
+export default BlogPostCard
diff --git a/themes/heo/components/BlogPostCardInfo.js b/themes/heo/components/BlogPostCardInfo.js
new file mode 100644
index 00000000..4b7feee6
--- /dev/null
+++ b/themes/heo/components/BlogPostCardInfo.js
@@ -0,0 +1,93 @@
+import NotionPage from '@/components/NotionPage'
+import Link from 'next/link'
+import TagItemMini from './TagItemMini'
+import TwikooCommentCount from '@/components/TwikooCommentCount'
+import BLOG from '@/blog.config'
+
+/**
+ * 博客列表的文字内容
+ * @param {*} param0
+ * @returns
+ */
+export const BlogPostCardInfo = ({ post, showPreview, showPageCover, showSummary }) => {
+ return
+
+ {/* 标题 */}
+
+
+
{post.title}
+
+
+
+ {/* 分类 */}
+ { post?.category &&
+
+
+
+ {post.category}
+
+
+
+
+
}
+
+ {/* 摘要 */}
+ {(!showPreview || showSummary) && !post.results && (
+
+ {post.summary}
+
+ )}
+
+ {/* 搜索结果 */}
+ {post.results && (
+
+ {post.results.map(r => (
+ {r}
+ ))}
+
+ )}
+ {/* 预览 */}
+ {showPreview && (
+
+
+
+ )}
+
+
+
+
+ {/* 日期标签 */}
+
+ {/* 日期 */}
+
+
+
+ {post?.publishTime || post.lastEditedTime}
+
+
+
+
+
+ {' '}
+ {post.tagItems?.map(tag => (
+
+ ))}
+
+
+
+
+
+}
diff --git a/themes/heo/components/BlogPostListEmpty.js b/themes/heo/components/BlogPostListEmpty.js
new file mode 100644
index 00000000..5f75c3e7
--- /dev/null
+++ b/themes/heo/components/BlogPostListEmpty.js
@@ -0,0 +1,14 @@
+import { useGlobal } from '@/lib/global'
+
+/**
+ * 空白博客 列表
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const BlogPostListEmpty = ({ currentSearch }) => {
+ const { locale } = useGlobal()
+ return
+
{locale.COMMON.NO_MORE} {(currentSearch &&
{currentSearch}
)}
+
+}
+export default BlogPostListEmpty
diff --git a/themes/heo/components/BlogPostListPage.js b/themes/heo/components/BlogPostListPage.js
new file mode 100644
index 00000000..92008f83
--- /dev/null
+++ b/themes/heo/components/BlogPostListPage.js
@@ -0,0 +1,34 @@
+import BlogPostCard from './BlogPostCard'
+import PaginationNumber from './PaginationNumber'
+import BLOG from '@/blog.config'
+import BlogPostListEmpty from './BlogPostListEmpty'
+
+/**
+ * 文章列表分页表格
+ * @param page 当前页
+ * @param posts 所有文章
+ * @param tags 所有标签
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const BlogPostListPage = ({ page = 1, posts = [], postCount, siteInfo }) => {
+ const totalPage = Math.ceil(postCount / BLOG.POSTS_PER_PAGE)
+ const showPagination = postCount >= BLOG.POSTS_PER_PAGE
+ if (!posts || posts.length === 0 || page > totalPage) {
+ return
+ } else {
+ return (
+
+ {/* 文章列表 */}
+
+ {posts?.map(post => (
+
+ ))}
+
+ {showPagination &&
}
+
+ )
+ }
+}
+
+export default BlogPostListPage
diff --git a/themes/heo/components/BlogPostListScroll.js b/themes/heo/components/BlogPostListScroll.js
new file mode 100644
index 00000000..830e8177
--- /dev/null
+++ b/themes/heo/components/BlogPostListScroll.js
@@ -0,0 +1,75 @@
+import BLOG from '@/blog.config'
+import BlogPostCard from './BlogPostCard'
+import BlogPostListEmpty from './BlogPostListEmpty'
+import { useGlobal } from '@/lib/global'
+import React from 'react'
+import CONFIG from '../config'
+import { getListByPage } from '@/lib/utils'
+
+/**
+ * 博客列表滚动分页
+ * @param posts 所有文章
+ * @param tags 所有标签
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const BlogPostListScroll = ({ posts = [], currentSearch, showSummary = CONFIG.POST_LIST_SUMMARY, siteInfo }) => {
+ const postsPerPage = BLOG.POSTS_PER_PAGE
+ const [page, updatePage] = React.useState(1)
+ const postsToShow = getListByPage(posts, page, postsPerPage)
+
+ let hasMore = false
+ if (posts) {
+ const totalCount = posts.length
+ hasMore = page * postsPerPage < totalCount
+ }
+
+ const handleGetMore = () => {
+ if (!hasMore) return
+ updatePage(page + 1)
+ }
+
+ // 监听滚动自动分页加载
+ const scrollTrigger = () => {
+ requestAnimationFrame(() => {
+ const scrollS = window.scrollY + window.outerHeight
+ const clientHeight = targetRef ? (targetRef.current ? (targetRef.current.clientHeight) : 0) : 0
+ if (scrollS > clientHeight + 100) {
+ handleGetMore()
+ }
+ })
+ }
+
+ // 监听滚动
+ React.useEffect(() => {
+ window.addEventListener('scroll', scrollTrigger)
+ return () => {
+ window.removeEventListener('scroll', scrollTrigger)
+ }
+ })
+
+ const targetRef = React.useRef(null)
+ const { locale } = useGlobal()
+
+ if (!postsToShow || postsToShow.length === 0) {
+ return
+ } else {
+ return
+
+ {/* 文章列表 */}
+
+ {postsToShow.map(post => (
+
+ ))}
+
+
+
+
{ handleGetMore() }}
+ className='w-full my-4 py-4 text-center cursor-pointer rounded-xl dark:text-gray-200'
+ > {hasMore ? locale.COMMON.MORE : `${locale.COMMON.NO_MORE}`}
+
+
+ }
+}
+
+export default BlogPostListScroll
diff --git a/themes/heo/components/Card.js b/themes/heo/components/Card.js
new file mode 100644
index 00000000..c2db0e49
--- /dev/null
+++ b/themes/heo/components/Card.js
@@ -0,0 +1,9 @@
+const Card = ({ children, headerSlot, className }) => {
+ return
+ <>{headerSlot}>
+
+
+}
+export default Card
diff --git a/themes/heo/components/Catalog.js b/themes/heo/components/Catalog.js
new file mode 100644
index 00000000..980be47b
--- /dev/null
+++ b/themes/heo/components/Catalog.js
@@ -0,0 +1,95 @@
+import React, { useRef } from 'react'
+import throttle from 'lodash.throttle'
+import { uuidToId } from 'notion-utils'
+import Progress from './Progress'
+import { useGlobal } from '@/lib/global'
+
+/**
+ * 目录导航组件
+ * @param toc
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const Catalog = ({ toc }) => {
+ const { locale } = useGlobal()
+ // 监听滚动事件
+ React.useEffect(() => {
+ window.addEventListener('scroll', actionSectionScrollSpy)
+ actionSectionScrollSpy()
+ return () => {
+ window.removeEventListener('scroll', actionSectionScrollSpy)
+ }
+ }, [])
+
+ // 目录自动滚动
+ const tRef = useRef(null)
+ const tocIds = []
+
+ // 同步选中目录事件
+ const [activeSection, setActiveSection] = React.useState(null)
+
+ const throttleMs = 200
+ const actionSectionScrollSpy = React.useCallback(throttle(() => {
+ const sections = document.getElementsByClassName('notion-h')
+ let prevBBox = null
+ let currentSectionId = activeSection
+ 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 index = tocIds.indexOf(currentSectionId) || 0
+ tRef?.current?.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/themes/heo/components/CategoryGroup.js b/themes/heo/components/CategoryGroup.js
new file mode 100644
index 00000000..c88df60a
--- /dev/null
+++ b/themes/heo/components/CategoryGroup.js
@@ -0,0 +1,31 @@
+import Link from 'next/link'
+import React from 'react'
+
+const CategoryGroup = ({ currentCategory, categories }) => {
+ if (!categories) {
+ return <>>
+ }
+ return <>
+
+ {categories.map(category => {
+ const selected = currentCategory === category.name
+ return (
+
+
+
{category.name}({category.count})
+
+
+ );
+ })}
+
+ >;
+}
+
+export default CategoryGroup
diff --git a/themes/heo/components/FloatDarkModeButton.js b/themes/heo/components/FloatDarkModeButton.js
new file mode 100644
index 00000000..f693d1f0
--- /dev/null
+++ b/themes/heo/components/FloatDarkModeButton.js
@@ -0,0 +1,31 @@
+import { useGlobal } from '@/lib/global'
+import { saveDarkModeToCookies } from '@/themes/theme'
+import CONFIG from '../config'
+
+export default function FloatDarkModeButton () {
+ const { isDarkMode, updateDarkMode } = useGlobal()
+
+ if (!CONFIG.WIDGET_DARK_MODE) {
+ return <>>
+ }
+
+ // 用户手动设置主题
+ const handleChangeDarkMode = () => {
+ const newStatus = !isDarkMode
+ saveDarkModeToCookies(newStatus)
+ updateDarkMode(newStatus)
+ const htmlElement = document.getElementsByTagName('html')[0]
+ htmlElement.classList?.remove(newStatus ? 'light' : 'dark')
+ htmlElement.classList?.add(newStatus ? 'dark' : 'light')
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/themes/heo/components/Footer.js b/themes/heo/components/Footer.js
new file mode 100644
index 00000000..f67b53f2
--- /dev/null
+++ b/themes/heo/components/Footer.js
@@ -0,0 +1,36 @@
+import React from 'react'
+import BLOG from '@/blog.config'
+// import DarkModeButton from '@/components/DarkModeButton'
+
+const Footer = ({ title }) => {
+ const d = new Date()
+ const currentYear = d.getFullYear()
+ const copyrightDate = (function() {
+ if (Number.isInteger(BLOG.SINCE) && BLOG.SINCE < currentYear) {
+ return BLOG.SINCE + '-' + currentYear
+ }
+ return currentYear
+ })()
+
+ return (
+
+ )
+}
+
+export default Footer
diff --git a/themes/heo/components/Header.js b/themes/heo/components/Header.js
new file mode 100644
index 00000000..76e86c25
--- /dev/null
+++ b/themes/heo/components/Header.js
@@ -0,0 +1,82 @@
+// import Image from 'next/image'
+import { useEffect, useState } from 'react'
+import Typed from 'typed.js'
+import CONFIG from '../config'
+import NavButtonGroup from './NavButtonGroup'
+import { useGlobal } from '@/lib/global'
+import BLOG from '@/blog.config'
+
+let wrapperTop = 0
+
+/**
+ * 顶部全屏大图
+ * @returns
+ */
+const Hero = props => {
+ const [typed, changeType] = useState()
+ const { siteInfo } = props
+ const { locale } = useGlobal()
+ const scrollToWrapper = () => {
+ window.scrollTo({ top: wrapperTop, behavior: 'smooth' })
+ }
+ useEffect(() => {
+ updateHeaderHeight()
+
+ if (!typed && window && document.getElementById('typed')) {
+ changeType(
+ new Typed('#typed', {
+ strings: BLOG.GREETING_WORDS.split(','),
+ typeSpeed: 200,
+ backSpeed: 100,
+ backDelay: 400,
+ showCursor: true,
+ smartBackspace: true
+ })
+ )
+ }
+
+ window.addEventListener('resize', updateHeaderHeight)
+ return () => {
+ window.removeEventListener('resize', updateHeaderHeight)
+ }
+ })
+
+ function updateHeaderHeight() {
+ requestAnimationFrame(() => {
+ const wrapperElement = document.getElementById('wrapper')
+ wrapperTop = wrapperElement?.offsetTop
+ })
+ }
+
+ return (
+
+ )
+}
+
+export default Hero
diff --git a/themes/heo/components/HeaderArticle.js b/themes/heo/components/HeaderArticle.js
new file mode 100644
index 00000000..b5384a1f
--- /dev/null
+++ b/themes/heo/components/HeaderArticle.js
@@ -0,0 +1,79 @@
+import Link from 'next/link'
+import TagItemMini from './TagItemMini'
+import { useGlobal } from '@/lib/global'
+import BLOG from '@/blog.config'
+import NotionIcon from '@/components/NotionIcon'
+
+export default function HeaderArticle({ post, siteInfo }) {
+ const { locale } = useGlobal()
+
+ if (!post) {
+ return <>>
+ }
+ const headerImage = post?.pageCover ? `url("${post.pageCover}")` : `url("${siteInfo?.pageCover}")`
+
+ return (
+
+ )
+}
diff --git a/themes/heo/components/HexoRecentComments.js b/themes/heo/components/HexoRecentComments.js
new file mode 100644
index 00000000..2ebf00c8
--- /dev/null
+++ b/themes/heo/components/HexoRecentComments.js
@@ -0,0 +1,47 @@
+import React from 'react'
+import BLOG from '@/blog.config'
+import Card from '@/themes/hexo/components/Card'
+import { useGlobal } from '@/lib/global'
+import Link from 'next/link'
+import { RecentComments } from '@waline/client'
+
+/**
+ * @see https://waline.js.org/guide/get-started.html
+ * @param {*} props
+ * @returns
+ */
+const HexoRecentComments = (props) => {
+ const [comments, updateComments] = React.useState([])
+ const { locale } = useGlobal()
+ const [onLoading, changeLoading] = React.useState(true)
+ React.useEffect(() => {
+ RecentComments({
+ serverURL: BLOG.COMMENT_WALINE_SERVER_URL,
+ count: 5
+ }).then(({ comments }) => {
+ changeLoading(false)
+ updateComments(comments)
+ })
+ }, [])
+
+ return (
+
+
+
+ {locale.COMMON.RECENT_COMMENTS}
+
+
+ {onLoading && Loading...
}
+ {!onLoading && comments && comments.length === 0 && No Comments
}
+ {!onLoading && comments && comments.length > 0 && comments.map((comment) =>
+
+
+ --{comment.nick}
+
+
)}
+
+
+ )
+}
+
+export default HexoRecentComments
diff --git a/themes/heo/components/InfoCard.js b/themes/heo/components/InfoCard.js
new file mode 100644
index 00000000..661da999
--- /dev/null
+++ b/themes/heo/components/InfoCard.js
@@ -0,0 +1,32 @@
+import BLOG from '@/blog.config'
+import { useRouter } from 'next/router'
+import Card from './Card'
+import SocialButton from './SocialButton'
+import MenuGroupCard from './MenuGroupCard'
+
+/**
+ * 社交信息卡
+ * @param {*} props
+ * @returns
+ */
+export function InfoCard(props) {
+ const { className, siteInfo } = props
+ const router = useRouter()
+ return (
+
+ {
+ router.push('/')
+ }}
+ >
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+ {BLOG.AUTHOR}
+ {BLOG.BIO}
+
+
+
+ )
+}
diff --git a/themes/heo/components/JumpToCommentButton.js b/themes/heo/components/JumpToCommentButton.js
new file mode 100644
index 00000000..fb007712
--- /dev/null
+++ b/themes/heo/components/JumpToCommentButton.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import CONFIG from '../config'
+
+/**
+ * 跳转到评论区
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const JumpToCommentButton = () => {
+ if (!CONFIG.WIDGET_TO_COMMENT) {
+ return <>>
+ }
+
+ function navToComment() {
+ if (document.getElementById('comment')) {
+ window.scrollTo({ top: document.getElementById('comment').offsetTop, behavior: 'smooth' })
+ }
+ // 兼容性不好
+ // const commentElement = document.getElementById('comment')
+ // if (commentElement) {
+ // commentElement?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
+ }
+
+ return (
+
+
)
+}
+
+export default JumpToCommentButton
diff --git a/themes/heo/components/JumpToTopButton.js b/themes/heo/components/JumpToTopButton.js
new file mode 100644
index 00000000..77313f46
--- /dev/null
+++ b/themes/heo/components/JumpToTopButton.js
@@ -0,0 +1,25 @@
+import { useGlobal } from '@/lib/global'
+import React from 'react'
+import CONFIG from '../config'
+
+/**
+ * 跳转到网页顶部
+ * 当屏幕下滑500像素后会出现该控件
+ * @param targetRef 关联高度的目标html标签
+ * @param showPercent 是否显示百分比
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const JumpToTopButton = ({ showPercent = true, percent }) => {
+ const { locale } = useGlobal()
+
+ if (!CONFIG.WIDGET_TO_TOP) {
+ return <>>
+ }
+ return ( window.scrollTo({ top: 0, behavior: 'smooth' })} >
+
+ {showPercent && (
{percent}
)}
+
)
+}
+
+export default JumpToTopButton
diff --git a/themes/heo/components/LatestPostsGroup.js b/themes/heo/components/LatestPostsGroup.js
new file mode 100644
index 00000000..22ac3e42
--- /dev/null
+++ b/themes/heo/components/LatestPostsGroup.js
@@ -0,0 +1,72 @@
+import BLOG from '@/blog.config'
+import { useGlobal } from '@/lib/global'
+// import Image from 'next/image'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+
+/**
+ * 最新文章列表
+ * @param posts 所有文章数据
+ * @param sliceCount 截取展示的数量 默认6
+ * @constructor
+ */
+const LatestPostsGroup = ({ latestPosts, siteInfo }) => {
+ // 获取当前路径
+ const currentPath = useRouter().asPath
+ const { locale } = useGlobal()
+
+ if (!latestPosts) {
+ return <>>
+ }
+
+ return <>
+
+
+
+ {locale.COMMON.LATEST_POSTS}
+
+
+ {latestPosts.map(post => {
+ const selected = currentPath === `${BLOG.SUB_PATH}/${post.slug}`
+
+ const headerImage = post?.pageCoverThumbnail ? post.pageCoverThumbnail : siteInfo?.pageCover
+
+ return (
+ (
+
+
+ {/*
*/}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+
+
{post.title}
+
{post.lastEditedTime}
+
+
+
+ )
+ )
+ })}
+ >
+}
+export default LatestPostsGroup
diff --git a/themes/heo/components/LoadingCover.js b/themes/heo/components/LoadingCover.js
new file mode 100644
index 00000000..c6418fad
--- /dev/null
+++ b/themes/heo/components/LoadingCover.js
@@ -0,0 +1,8 @@
+export default function LoadingCover () {
+ return (
+ )
+}
diff --git a/themes/heo/components/Logo.js b/themes/heo/components/Logo.js
new file mode 100644
index 00000000..2dd392b7
--- /dev/null
+++ b/themes/heo/components/Logo.js
@@ -0,0 +1,15 @@
+import BLOG from '@/blog.config'
+import Link from 'next/link'
+import React from 'react'
+
+const Logo = props => {
+ const { siteInfo } = props
+ return (
+
+
+
{siteInfo?.title || BLOG.TITLE}
+
+
+ )
+}
+export default Logo
diff --git a/themes/heo/components/MenuGroupCard.js b/themes/heo/components/MenuGroupCard.js
new file mode 100644
index 00000000..25dba50c
--- /dev/null
+++ b/themes/heo/components/MenuGroupCard.js
@@ -0,0 +1,45 @@
+import React from 'react'
+import Link from 'next/link'
+import { useGlobal } from '@/lib/global'
+import CONFIG from '../config'
+
+const MenuGroupCard = (props) => {
+ const { postCount, categoryOptions, tagOptions } = props
+ const { locale } = useGlobal()
+ const archiveSlot = {postCount}
+ const categorySlot = {categoryOptions?.length}
+ const tagSlot = {tagOptions?.length}
+
+ const links = [
+ { name: locale.COMMON.ARTICLE, to: '/archive', slot: archiveSlot, show: CONFIG.MENU_ARCHIVE },
+ { name: locale.COMMON.CATEGORY, to: '/category', slot: categorySlot, show: CONFIG.MENU_CATEGORY },
+ { name: locale.COMMON.TAGS, to: '/tag', slot: tagSlot, show: CONFIG.MENU_TAG }
+ ]
+
+ return (
+
+ )
+}
+export default MenuGroupCard
diff --git a/themes/heo/components/MenuItemCollapse.js b/themes/heo/components/MenuItemCollapse.js
new file mode 100644
index 00000000..3ec10f5e
--- /dev/null
+++ b/themes/heo/components/MenuItemCollapse.js
@@ -0,0 +1,54 @@
+import Collapse from '@/components/Collapse'
+import Link from 'next/link'
+import { useState } from 'react'
+
+/**
+ * 折叠菜单
+ * @param {*} param0
+ * @returns
+ */
+export const MenuItemCollapse = ({ link }) => {
+ 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 => {
+ return
+
+ {link?.icon && } {sLink.title}
+
+
+ })}
+ }
+ >
+}
diff --git a/themes/heo/components/MenuItemDrop.js b/themes/heo/components/MenuItemDrop.js
new file mode 100644
index 00000000..2dfb6f79
--- /dev/null
+++ b/themes/heo/components/MenuItemDrop.js
@@ -0,0 +1,41 @@
+import Link from 'next/link'
+import { useState } from 'react'
+
+export const MenuItemDrop = ({ link }) => {
+ const [show, changeShow] = useState(false)
+ const hasSubMenu = link?.subMenus?.length > 0
+
+ if (!link || !link.show) {
+ return null
+ }
+
+ return changeShow(true)} onMouseOut={() => changeShow(false)} >
+
+ {!hasSubMenu &&
+
+ {link?.icon &&
} {link?.name}
+ {hasSubMenu && }
+ }
+
+ {hasSubMenu && <>
+
+ {link?.icon && } {link?.name}
+
+
+ >}
+
+ {/* 子菜单 */}
+ {hasSubMenu &&
+ {link.subMenus.map((sLink, index) => {
+ return -
+
+ {link?.icon && }{sLink.title}
+
+
+ })}
+
}
+
+
+}
diff --git a/themes/heo/components/MenuListSide.js b/themes/heo/components/MenuListSide.js
new file mode 100644
index 00000000..1a3b2f5b
--- /dev/null
+++ b/themes/heo/components/MenuListSide.js
@@ -0,0 +1,37 @@
+import React from 'react'
+import { useGlobal } from '@/lib/global'
+import BLOG from '@/blog.config'
+import { MenuItemCollapse } from './MenuItemCollapse'
+import CONFIG from '../config'
+
+export const MenuListSide = (props) => {
+ const { customNav, customMenu } = props
+ const { locale } = useGlobal()
+
+ let links = [
+ { icon: 'fas fa-archive', name: locale.NAV.ARCHIVE, to: '/archive', show: CONFIG.MENU_ARCHIVE },
+ { icon: 'fas fa-search', name: locale.NAV.SEARCH, to: '/search', show: CONFIG.MENU_SEARCH },
+ { icon: 'fas fa-folder', name: locale.COMMON.CATEGORY, to: '/category', show: CONFIG.MENU_CATEGORY },
+ { icon: 'fas fa-tag', name: locale.COMMON.TAGS, to: '/tag', show: CONFIG.MENU_TAG }
+ ]
+
+ if (customNav) {
+ links = customNav.concat(links)
+ }
+
+ // 如果 开启自定义菜单,则覆盖Page生成的菜单
+ if (BLOG.CUSTOM_MENU) {
+ links = customMenu
+ }
+
+ if (!links || links.length === 0) {
+ return null
+ }
+
+ return (
+
+ )
+}
diff --git a/themes/heo/components/MenuListTop.js b/themes/heo/components/MenuListTop.js
new file mode 100644
index 00000000..a2fd66ad
--- /dev/null
+++ b/themes/heo/components/MenuListTop.js
@@ -0,0 +1,37 @@
+import React from 'react'
+import { useGlobal } from '@/lib/global'
+import CONFIG from '../config'
+import BLOG from '@/blog.config'
+import { MenuItemDrop } from './MenuItemDrop'
+
+export const MenuListTop = (props) => {
+ const { customNav, customMenu } = props
+ const { locale } = useGlobal()
+
+ let links = [
+ { id: 1, icon: 'fa-solid fa-house', name: locale.NAV.INDEX, to: '/', show: CONFIG.MENU_INDEX },
+ { id: 2, icon: 'fas fa-search', name: locale.NAV.SEARCH, to: '/search', show: CONFIG.MENU_SEARCH },
+ { id: 3, icon: 'fas fa-archive', name: locale.NAV.ARCHIVE, to: '/archive', show: CONFIG.MENU_ARCHIVE }
+ // { icon: 'fas fa-folder', name: locale.COMMON.CATEGORY, to: '/category', show: CONFIG.MENU_CATEGORY },
+ // { icon: 'fas fa-tag', name: locale.COMMON.TAGS, to: '/tag', show: CONFIG.MENU_TAG }
+ ]
+
+ if (customNav) {
+ links = links.concat(customNav)
+ }
+
+ // 如果 开启自定义菜单,则覆盖Page生成的菜单
+ if (BLOG.CUSTOM_MENU) {
+ links = customMenu
+ }
+
+ if (!links || links.length === 0) {
+ return null
+ }
+
+ return (<>
+
+ >)
+}
diff --git a/themes/heo/components/NavButtonGroup.js b/themes/heo/components/NavButtonGroup.js
new file mode 100644
index 00000000..2a3fc898
--- /dev/null
+++ b/themes/heo/components/NavButtonGroup.js
@@ -0,0 +1,33 @@
+
+import React from 'react'
+import Link from 'next/link'
+
+/**
+ * 首页导航大按钮组件
+ * @param {*} props
+ * @returns
+ */
+const NavButtonGroup = (props) => {
+ const { categoryOptions } = props
+ if (!categoryOptions || categoryOptions.length === 0) {
+ return <>>
+ }
+
+ return (
+
+ )
+}
+export default NavButtonGroup
diff --git a/themes/heo/components/PaginationNumber.js b/themes/heo/components/PaginationNumber.js
new file mode 100644
index 00000000..42281b93
--- /dev/null
+++ b/themes/heo/components/PaginationNumber.js
@@ -0,0 +1,107 @@
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+
+/**
+ * 数字翻页插件
+ * @param page 当前页码
+ * @param showNext 是否有下一页
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const PaginationNumber = ({ page, totalPage }) => {
+ const router = useRouter()
+ const currentPage = +page
+ const showNext = page < totalPage
+ const pagePrefix = router.asPath.split('?')[0].replace(/\/page\/[1-9]\d*/, '').replace(/\/$/, '')
+ const pages = generatePages(pagePrefix, page, currentPage, totalPage)
+
+ return (
+
+ {/* 上一页 */}
+
+
+
+
+
+
+ {pages}
+
+ {/* 下一页 */}
+
+
+
+
+
+
+ )
+}
+
+function getPageElement(page, currentPage, pagePrefix) {
+ return (
+ (
+
+ {page}
+
+ )
+ )
+}
+
+function generatePages(pagePrefix, page, currentPage, totalPage) {
+ const pages = []
+ const groupCount = 7 // 最多显示页签数
+ if (totalPage <= groupCount) {
+ for (let i = 1; i <= totalPage; i++) {
+ pages.push(getPageElement(i, page, pagePrefix))
+ }
+ } else {
+ pages.push(getPageElement(1, page, pagePrefix))
+ const dynamicGroupCount = groupCount - 2
+ let startPage = currentPage - 2
+ if (startPage <= 1) {
+ startPage = 2
+ }
+ if (startPage + dynamicGroupCount > totalPage) {
+ startPage = totalPage - dynamicGroupCount
+ }
+ if (startPage > 2) {
+ pages.push(...
)
+ }
+
+ for (let i = 0; i < dynamicGroupCount; i++) {
+ if (startPage + i < totalPage) {
+ pages.push(getPageElement(startPage + i, page, pagePrefix))
+ }
+ }
+
+ if (startPage + dynamicGroupCount < totalPage) {
+ pages.push(...
)
+ }
+
+ pages.push(getPageElement(totalPage, page, pagePrefix))
+ }
+ return pages
+}
+export default PaginationNumber
diff --git a/themes/heo/components/Progress.js b/themes/heo/components/Progress.js
new file mode 100644
index 00000000..5b4f9b20
--- /dev/null
+++ b/themes/heo/components/Progress.js
@@ -0,0 +1,44 @@
+import React, { 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('article-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/themes/heo/components/RightFloatArea.js b/themes/heo/components/RightFloatArea.js
new file mode 100644
index 00000000..d7fadce5
--- /dev/null
+++ b/themes/heo/components/RightFloatArea.js
@@ -0,0 +1,42 @@
+import throttle from 'lodash.throttle'
+import { useCallback, useEffect, useState } from 'react'
+import FloatDarkModeButton from './FloatDarkModeButton'
+import JumpToTopButton from './JumpToTopButton'
+
+/**
+ * 悬浮在右下角的按钮,当页面向下滚动100px时会出现
+ * @param {*} param0
+ * @returns
+ */
+export default function RightFloatArea({ floatSlot }) {
+ const [showFloatButton, switchShow] = useState(false)
+ const scrollListener = useCallback(throttle(() => {
+ const targetRef = document.getElementById('wrapper')
+ const clientHeight = targetRef?.clientHeight
+ const scrollY = window.pageYOffset
+ const fullHeight = clientHeight - window.outerHeight
+ let per = parseFloat(((scrollY / fullHeight) * 100).toFixed(0))
+ if (per > 100) per = 100
+ const shouldShow = scrollY > 100 && per > 0
+
+ // 右下角显示悬浮按钮
+ if (shouldShow !== showFloatButton) {
+ switchShow(shouldShow)
+ }
+ }, 200))
+
+ useEffect(() => {
+ document.addEventListener('scroll', scrollListener)
+ return () => document.removeEventListener('scroll', scrollListener)
+ }, [])
+
+ return (
+
+ )
+}
diff --git a/themes/heo/components/SearchDrawer.js b/themes/heo/components/SearchDrawer.js
new file mode 100644
index 00000000..c7ec88a7
--- /dev/null
+++ b/themes/heo/components/SearchDrawer.js
@@ -0,0 +1,36 @@
+import { Router } from 'next/router'
+import { useImperativeHandle, useRef } from 'react'
+import SearchInput from './SearchInput'
+const SearchDrawer = ({ cRef, slot }) => {
+ const searchDrawer = useRef()
+ const searchInputRef = useRef()
+ useImperativeHandle(cRef, () => {
+ return {
+ show: () => {
+ searchDrawer?.current?.classList?.remove('hidden')
+ searchInputRef?.current?.focus()
+ }
+ }
+ })
+ const hidden = () => {
+ searchDrawer?.current?.classList?.add('hidden')
+ }
+ Router.events.on('routeChangeComplete', (...args) => {
+ hidden()
+ })
+ return (
+
+ )
+}
+
+export default SearchDrawer
diff --git a/themes/heo/components/SearchInput.js b/themes/heo/components/SearchInput.js
new file mode 100644
index 00000000..462c58b3
--- /dev/null
+++ b/themes/heo/components/SearchInput.js
@@ -0,0 +1,106 @@
+import { useRouter } from 'next/router'
+import { useImperativeHandle, useRef, useState } from 'react'
+import { useGlobal } from '@/lib/global'
+let lock = false
+
+const SearchInput = props => {
+ const { currentSearch, cRef, className } = props
+ const [onLoading, setLoadingState] = useState(false)
+ const router = useRouter()
+ const searchInputRef = useRef()
+ const { locale } = useGlobal()
+ useImperativeHandle(cRef, () => {
+ return {
+ focus: () => {
+ searchInputRef?.current?.focus()
+ }
+ }
+ })
+
+ const handleSearch = () => {
+ const key = searchInputRef.current.value
+ if (key && key !== '') {
+ setLoadingState(true)
+ router.push({ pathname: '/search/' + key }).then(r => {
+ setLoadingState(false)
+ })
+ // location.href = '/search/' + key
+ } else {
+ router.push({ pathname: '/' }).then(r => {})
+ }
+ }
+ const handleKeyUp = e => {
+ if (e.keyCode === 13) {
+ // 回车
+ handleSearch(searchInputRef.current.value)
+ } else if (e.keyCode === 27) {
+ // ESC
+ cleanSearch()
+ }
+ }
+ const cleanSearch = () => {
+ searchInputRef.current.value = ''
+ }
+
+ 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 || ''}
+ />
+
+
+
+
+
+ {showClean && (
+
+
+
+ )}
+
+ )
+}
+
+export default SearchInput
diff --git a/themes/heo/components/SearchNav.js b/themes/heo/components/SearchNav.js
new file mode 100644
index 00000000..359f5c8c
--- /dev/null
+++ b/themes/heo/components/SearchNav.js
@@ -0,0 +1,70 @@
+import { useGlobal } from '@/lib/global'
+import Link from 'next/link'
+import { useEffect, useRef } from 'react'
+import Card from './Card'
+import SearchInput from './SearchInput'
+import TagItemMini from './TagItemMini'
+
+/**
+ * 搜索页面的导航
+ * @param {*} props
+ * @returns
+ */
+export default function SearchNav(props) {
+ const { tagOptions, categoryOptions } = props
+ const cRef = useRef(null)
+ const { locale } = useGlobal()
+ useEffect(() => {
+ // 自动聚焦到搜索框
+ cRef?.current?.focus()
+ }, [])
+
+ return <>
+
+
+ {/* 分类 */}
+
+
+
+ {locale.COMMON.CATEGORY}:
+
+
+ {categoryOptions?.map(category => {
+ return (
+
+
+
+ {category.name}({category.count})
+
+
+ )
+ })}
+
+
+ {/* 标签 */}
+
+
+
+ {locale.COMMON.TAGS}:
+
+
+
+
+>
+}
diff --git a/themes/heo/components/SideBar.js b/themes/heo/components/SideBar.js
new file mode 100644
index 00000000..e43c4e12
--- /dev/null
+++ b/themes/heo/components/SideBar.js
@@ -0,0 +1,33 @@
+import BLOG from '@/blog.config'
+import { useRouter } from 'next/router'
+import MenuGroupCard from './MenuGroupCard'
+import { MenuListSide } from './MenuListSide'
+
+/**
+ * 侧边抽屉
+ * @param tags
+ * @param currentTag
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const SideBar = (props) => {
+ const { siteInfo } = props
+ const router = useRouter()
+ return (
+
+
+
+
{ router.push('/') }}
+ className='justify-center items-center flex hover:rotate-45 py-6 hover:scale-105 dark:text-gray-100 transform duration-200 cursor-pointer'>
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+
+
+
+
+ )
+}
+
+export default SideBar
diff --git a/themes/heo/components/SideBarDrawer.js b/themes/heo/components/SideBarDrawer.js
new file mode 100644
index 00000000..87125c05
--- /dev/null
+++ b/themes/heo/components/SideBarDrawer.js
@@ -0,0 +1,51 @@
+import { useRouter } from 'next/router'
+import { useEffect } from 'react'
+
+/**
+ * 侧边栏抽屉面板,可以从侧面拉出
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const SideBarDrawer = ({ children, isOpen, onOpen, onClose, className }) => {
+ const router = useRouter()
+ useEffect(() => {
+ const sideBarDrawerRouteListener = () => {
+ switchSideDrawerVisible(false)
+ }
+ router.events.on('routeChangeComplete', sideBarDrawerRouteListener)
+ return () => {
+ router.events.off('routeChangeComplete', sideBarDrawerRouteListener)
+ }
+ }, [router.events])
+
+ // 点击按钮更改侧边抽屉状态
+ const switchSideDrawerVisible = (showStatus) => {
+ if (showStatus) {
+ onOpen && onOpen()
+ } else {
+ onClose && onClose()
+ }
+ const sideBarDrawer = window.document.getElementById('sidebar-drawer')
+ const sideBarDrawerBackground = window.document.getElementById('sidebar-drawer-background')
+
+ if (showStatus) {
+ sideBarDrawer?.classList.replace('-mr-72', 'mr-0')
+ sideBarDrawerBackground?.classList.replace('hidden', 'block')
+ } else {
+ sideBarDrawer?.classList.replace('mr-0', '-mr-72')
+ sideBarDrawerBackground?.classList.replace('block', 'hidden')
+ }
+ }
+
+ return