diff --git a/.eslintrc.js b/.eslintrc.js index 4b95adcd..bab5ea27 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -26,6 +26,7 @@ module.exports = { } }, rules: { + 'react/no-unknown-property': 'off', // + + ) +} diff --git a/components/HeroIcons.js b/components/HeroIcons.js index 61e3ded8..6a24d8b7 100644 --- a/components/HeroIcons.js +++ b/components/HeroIcons.js @@ -3,52 +3,92 @@ * @returns */ -const Moon = () => { +export const Moon = () => { return - - + + } -const Sun = () => { +export const Sun = () => { return - - + + } -const Home = ({ className }) => { +export const Home = ({ className }) => { return - - + + } -const User = ({ className }) => { +export const User = ({ className }) => { return - - + + } -const ArrowPath = ({ className }) => { +export const ArrowPath = ({ className }) => { return - - + + } -const ChevronLeft = ({ className }) => { +export const ChevronLeft = ({ className }) => { return - - + + } -const ChevronRight = ({ className }) => { +export const ChevronRight = ({ className }) => { return - - + + } -const InformationCircle = ({ className }) => { +export const ChevronDoubleRight = ({ className }) => { return - + } -export { Moon, Sun, Home, User, ArrowPath, ChevronLeft, ChevronRight, InformationCircle } +export const InformationCircle = ({ className }) => { + return + + +} + +export const HashTag = ({ className }) => { + return + + +} + +export const GlobeAlt = ({ className }) => { + return + + +} + +export const ArrowRightCircle = ({ className }) => { + return + + +} + +export const PlusSmall = ({ className }) => { + return + + +} + +export const ArrowSmallRight = ({ className }) => { + return + + +} + +export const ArrowSmallUp = ({ className }) => { + return + + +} diff --git a/components/ThemeSwitch.js b/components/ThemeSwitch.js index 7ff3d40f..fc875dae 100644 --- a/components/ThemeSwitch.js +++ b/components/ThemeSwitch.js @@ -3,6 +3,7 @@ import React from 'react' import { Draggable } from './Draggable' import { THEMES } from '@/themes/theme' import { useRouter } from 'next/router' +import DarkModeButton from './DarkModeButton' /** * * @returns 主题切换 @@ -22,14 +23,15 @@ const ThemeSwitch = () => { return (<> -
-
- {THEMES?.map(t => { return })} - +
diff --git a/components/WordCount.js b/components/WordCount.js new file mode 100644 index 00000000..315c58d3 --- /dev/null +++ b/components/WordCount.js @@ -0,0 +1,67 @@ +import { useGlobal } from '@/lib/global' +import { useEffect } from 'react' + +/** + * 字数统计 + * @returns + */ +export default function WordCount() { + const { locale } = useGlobal() + useEffect(() => { + countWords() + }) + + return + + + 0 + + + + + 0 {locale.COMMON.MINUTE} + + +} + +/** + * 更新字数统计和阅读时间 + */ +function countWords() { + const articleText = deleteHtmlTag(document.getElementById('notion-article')?.innerHTML) + const wordCount = fnGetCpmisWords(articleText) + // 阅读速度 300-500每分钟 + document.getElementById('wordCount').innerHTML = wordCount + document.getElementById('readTime').innerHTML = Math.floor(wordCount / 400) + 1 + const wordCountWrapper = document.getElementById('wordCountWrapper') + wordCountWrapper.classList.remove('hidden') +} + +// 去除html标签 +function deleteHtmlTag(str) { + if (!str) { + return '' + } + str = str.replace(/<[^>]+>|&[^>]+;/g, '').trim()// 去掉所有的html标签和 之类的特殊符合 + return str +} + +// 用word方式计算正文字数 +function fnGetCpmisWords(str) { + if (!str) { + return 0 + } + let sLen = 0 + try { + // eslint-disable-next-line no-irregular-whitespace + str = str.replace(/(\r\n+|\s+| +)/g, '龘') + // eslint-disable-next-line no-control-regex + str = str.replace(/[\x00-\xff]/g, 'm') + str = str.replace(/m+/g, '*') + str = str.replace(/龘+/g, '') + sLen = str.length + } catch (e) { + + } + return sLen +} diff --git a/lib/lang/en-US.js b/lib/lang/en-US.js index de277434..7d036425 100644 --- a/lib/lang/en-US.js +++ b/lib/lang/en-US.js @@ -1,7 +1,7 @@ export default { LOCALE: 'en-US', NAV: { - INDEX: 'Blog', + INDEX: 'Home', RSS: 'RSS', SEARCH: 'Search', ABOUT: 'About', @@ -35,6 +35,7 @@ export default { SUBMIT: 'Submit', POST_TIME: 'Post on', LAST_EDITED_TIME: 'Last edited', + COMMENTS: 'Comments', RECENT_COMMENTS: 'Recent Comments', DEBUG_OPEN: 'Debug', DEBUG_CLOSE: 'Close', diff --git a/lib/lang/zh-CN.js b/lib/lang/zh-CN.js index 559afb91..fe130d6f 100644 --- a/lib/lang/zh-CN.js +++ b/lib/lang/zh-CN.js @@ -12,7 +12,7 @@ export default { COMMON: { MORE: '更多', NO_MORE: '没有更多了', - LATEST_POSTS: '最新文章', + LATEST_POSTS: '最新发布', TAGS: '标签', NO_TAG: 'NoTag', CATEGORY: '分类', @@ -37,6 +37,7 @@ export default { SUBMIT: '提交', POST_TIME: '发布于', LAST_EDITED_TIME: '最后更新', + COMMENTS: '评论', RECENT_COMMENTS: '最新评论', DEBUG_OPEN: '开启调试', DEBUG_CLOSE: '关闭调试', @@ -47,8 +48,8 @@ export default { WORD_COUNT: '字数' }, PAGINATION: { - PREV: '上一页', - NEXT: '下一页' + PREV: '上页', + NEXT: '下页' }, SEARCH: { ARTICLES: '搜索文章', diff --git a/lib/notion/getNotionData.js b/lib/notion/getNotionData.js index 89126d9c..fb6bc942 100644 --- a/lib/notion/getNotionData.js +++ b/lib/notion/getNotionData.js @@ -309,7 +309,7 @@ async function getDataBaseInfoByNotionAPI({ pageId, from }) { const customNav = getCustomNav({ allPages: collectionData.filter(post => post?.type === 'Page' && post.status === 'Published') }) // 新的菜单 const customMenu = await getCustomMenu({ collectionData }) - const latestPosts = getLatestPosts({ allPages, from, latestPostCount: 5 }) + const latestPosts = getLatestPosts({ allPages, from, latestPostCount: 6 }) const allNavPages = getNavPages({ allPages }) return { diff --git a/lib/notion/getPageProperties.js b/lib/notion/getPageProperties.js index 47e497ce..104a208e 100644 --- a/lib/notion/getPageProperties.js +++ b/lib/notion/getPageProperties.js @@ -99,7 +99,6 @@ export default async function getPageProperties(id, block, schema, authToken, ta properties.to = properties.slug ?? null properties.name = properties.title ?? '' } - properties.password = properties.password ? md5(properties.slug + properties.password) : '' // 开启伪静态路径 if (JSON.parse(BLOG.PSEUDO_STATIC)) { @@ -107,6 +106,7 @@ export default async function getPageProperties(id, block, schema, authToken, ta properties.slug += '.html' } } + properties.password = properties.password ? md5(properties.slug + properties.password) : '' return properties } diff --git a/pages/_document.js b/pages/_document.js index c053a632..6b7daa70 100644 --- a/pages/_document.js +++ b/pages/_document.js @@ -17,7 +17,7 @@ class MyDocument extends Document { - +
diff --git a/public/css/theme-fukasawa.css b/public/css/theme-fukasawa.css deleted file mode 100644 index d5a56963..00000000 --- a/public/css/theme-fukasawa.css +++ /dev/null @@ -1 +0,0 @@ -/* fukasawa的主题相关 */ \ No newline at end of file diff --git a/public/css/theme-hexo.css b/public/css/theme-hexo.css deleted file mode 100644 index ff509ec5..00000000 --- a/public/css/theme-hexo.css +++ /dev/null @@ -1,30 +0,0 @@ -/* 菜单下划线动画 */ -#theme-hexo .menu-link { - text-decoration: none; - background-image: linear-gradient(#928CEE, #928CEE); - background-repeat: no-repeat; - background-position: bottom center; - background-size: 0 2px; - transition: background-size 100ms ease-in-out; -} - -#theme-hexo .menu-link:hover { - background-size: 100% 2px; - color: #928CEE; -} - -/* 设置了从上到下的渐变黑色 */ -#theme-hexo .header-cover::before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.2) 10%, rgba(0,0,0,0) 25%, rgba(0,0,0,0.2) 75%, rgba(0,0,0,0.5) 100%); -} - -/* Custem */ -.tk-footer{ - opacity: 0; -} \ No newline at end of file diff --git a/public/css/theme-matery.css b/public/css/theme-matery.css deleted file mode 100644 index e3a01f01..00000000 --- a/public/css/theme-matery.css +++ /dev/null @@ -1,11 +0,0 @@ - -/* 设置了从上到下的渐变黑色 */ -#theme-matery .header-cover::before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.2) 10%, rgba(0,0,0,0) 25%, rgba(0,0,0,0.2) 75%, rgba(0,0,0,0.5) 100%); -} \ No newline at end of file diff --git a/public/css/theme-simple.css b/public/css/theme-simple.css deleted file mode 100644 index d02b7661..00000000 --- a/public/css/theme-simple.css +++ /dev/null @@ -1,34 +0,0 @@ -#theme-simple #announcement-content { - /* background-color: #f6f6f6; */ -} - -#theme-simple .blog-item-title { - color: #276077; -} - -.dark #theme-simple .blog-item-title { - color: #d1d5db; -} - -.notion { - margin-top: 0 !important; - margin-bottom: 0 !important; -} - - -/* 菜单下划线动画 */ -#theme-simple .menu-link { - text-decoration: none; - background-image: linear-gradient(#dd3333, #dd3333); - background-repeat: no-repeat; - background-position: bottom center; - background-size: 0 2px; - transition: background-size 100ms ease-in-out; -} - -#theme-simple .menu-link:hover { - background-size: 100% 2px; - color: #dd3333; - cursor: pointer; -} - diff --git a/styles/globals.css b/styles/globals.css index ae4d6aca..dbaa8ef2 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -2,28 +2,6 @@ @tailwind components; @tailwind utilities; -html { - --scrollbarBG: #ffffff00; - --thumbBG: #b8b8b8; -} -body::-webkit-scrollbar { - width: 5px; -} -body { - scrollbar-width: thin; - scrollbar-color: var(--thumbBG) var(--scrollbarBG); -} -body::-webkit-scrollbar-track { - background: var(--scrollbarBG); -} -body::-webkit-scrollbar-thumb { - background-color: var(--thumbBG); -} - -::selection { - background: rgba(45, 170, 219, 0.3); -} - .wrapper { min-height: 100vh; display: flex; @@ -285,57 +263,3 @@ a.avatar-wrapper { .reply-author-name { font-weight: 500; } - -.line-clamp-4 { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 4; - overflow: hidden; - text-overflow: ellipsis; -} - -.line-clamp-3 { - overflow: hidden; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; -} - -.line-clamp-2 { - overflow: hidden; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; -} - - -/* fukasawa的首页响应式分栏 */ -#theme-fukasawa .grid-item { - height: auto; - break-inside: avoid-column; - margin-bottom: .5rem; - } - - /* 大屏幕(宽度≥1024px)下显示3列 */ - @media (min-width: 1024px) { - #theme-fukasawa .grid-container { - column-count: 3; - column-gap: .5rem; - } - } - - /* 小屏幕(宽度≥640px)下显示2列 */ - @media (min-width: 640px) and (max-width: 1023px) { - #theme-fukasawa .grid-container { - column-count: 2; - column-gap: .5rem; - } - } - - /* 移动端(宽度<640px)下显示1列 */ - @media (max-width: 639px) { - #theme-fukasawa .grid-container { - column-count: 1; - column-gap: .5rem; - } - } \ No newline at end of file diff --git a/styles/notion.css b/styles/notion.css index 5fd5b4b8..b3bde97e 100644 --- a/styles/notion.css +++ b/styles/notion.css @@ -443,6 +443,7 @@ summary > .notion-h { .notion-h:hover .notion-hash-link { opacity: 1; + @apply dark:fill-gray-200 } .notion-hash-link { @@ -1943,10 +1944,6 @@ svg + .notion-page-title-text { display: block !important; } -::selection { - @apply bg-blue-500 text-gray-50 !important; -} - /* https://github.com/kchen0x */ .notion-quote { display: block; diff --git a/themes/example/index.js b/themes/example/index.js index 51986843..77ad1b3a 100644 --- a/themes/example/index.js +++ b/themes/example/index.js @@ -26,6 +26,7 @@ import CategoryItem from './components/CategoryItem' import TagItem from './components/TagItem' import { useRouter } from 'next/router' import { Transition } from '@headlessui/react' +import { Style } from './style' /** * 基础布局框架 @@ -50,6 +51,7 @@ const LayoutBase = props => {
{/* 网页SEO信息 */} + +} + +export { Style } diff --git a/themes/fukasawa/index.js b/themes/fukasawa/index.js index af73a189..aab7d3a0 100644 --- a/themes/fukasawa/index.js +++ b/themes/fukasawa/index.js @@ -5,7 +5,7 @@ import CommonHead from '@/components/CommonHead' import TopNav from './components/TopNav' import AsideLeft from './components/AsideLeft' import BLOG from '@/blog.config' -import { isBrowser, loadExternalResource } from '@/lib/utils' +import { isBrowser } from '@/lib/utils' import { useGlobal } from '@/lib/global' import BlogListPage from './components/BlogListPage' import BlogListScroll from './components/BlogListScroll' @@ -19,6 +19,7 @@ import Link from 'next/link' import { Transition } from '@headlessui/react' import dynamic from 'next/dynamic' import { AdSlot } from '@/components/GoogleAdsense' +import { Style } from './style' const Live2D = dynamic(() => import('@/components/Live2D')) const Mark = dynamic(() => import('mark.js')) @@ -61,15 +62,12 @@ const LayoutBase = (props) => { } }, [isCollapsed]) - if (isBrowser()) { - loadExternalResource('/css/theme-fukasawa.css', 'css') - } - return (
+ +} + +export { Style } diff --git a/themes/gitbook/index.js b/themes/gitbook/index.js index 5f1c105f..d3387d0e 100644 --- a/themes/gitbook/index.js +++ b/themes/gitbook/index.js @@ -30,6 +30,7 @@ import TocDrawer from './components/TocDrawer' import NotionPage from '@/components/NotionPage' import { ArticleLock } from './components/ArticleLock' import { Transition } from '@headlessui/react' +import { Style } from './style' // 主题全局变量 const ThemeGlobalGitbook = createContext() @@ -58,6 +59,7 @@ const LayoutBase = (props) => { return ( + +} + +export { Style } diff --git a/themes/heo/components/AnalyticsCard.js b/themes/heo/components/AnalyticsCard.js new file mode 100644 index 00000000..a9d30477 --- /dev/null +++ b/themes/heo/components/AnalyticsCard.js @@ -0,0 +1,43 @@ +import CONFIG from '../config' + +/** + * 博客统计卡牌 + * @param {*} props + * @returns + */ +export function AnalyticsCard(props) { + const targetDate = new Date(CONFIG.SITE_CREATE_TIME) + const today = new Date() + const diffTime = today.getTime() - targetDate.getTime() // 获取两个日期之间的毫秒数差值 + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) // 将毫秒数差值转换为天数差值 + + const { postCount } = props + return <> +
+
+
+
文章数:
+
{postCount}
+
+
+
+
+
建站天数:
+
{diffDays} 天
+
+
+
+
+
访问量:
+
+
+
+
+
+
访客数:
+
+
+
+
+ +} diff --git a/themes/heo/components/Announcement.js b/themes/heo/components/Announcement.js new file mode 100644 index 00000000..6c3feb2a --- /dev/null +++ b/themes/heo/components/Announcement.js @@ -0,0 +1,18 @@ +import dynamic from 'next/dynamic' + +const NotionPage = dynamic(() => import('@/components/NotionPage')) + +const Announcement = ({ post, className }) => { + if (post?.blockMap) { + return
+ {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..bebc7e69 --- /dev/null +++ b/themes/heo/components/ArticleAdjacent.js @@ -0,0 +1,83 @@ +import Link from 'next/link' +import { useEffect, useState } from 'react' +import CONFIG from '../config' + +/** + * 上一篇,下一篇文章 + * @param {prev,next} param0 + * @returns + */ +export default function ArticleAdjacent({ prev, next }) { + const [isScrollEnd, setIsScrollEnd] = useState(false) + + useEffect(() => { + // 文章是否已经到了底部 + const targetElement = document.getElementById('article-end') + + const handleIntersect = (entries) => { + entries.forEach((entry) => { + console.log(entry.isIntersecting) + if (entry.isIntersecting) { + setIsScrollEnd(true) + } else { + // setIsScrollEnd(false) + } + }) + } + + const options = { + root: null, + rootMargin: '0px', + threshold: 0.1 + } + + const observer = new IntersectionObserver(handleIntersect, options) + observer.observe(targetElement) + + return () => { + observer.disconnect() + } + }, []) + + if (!prev || !next || !CONFIG.ARTICLE_ADJACENT) { + return <> + } + + return ( +
+ {/* 移动端 */} +
+ +
上一篇
+
{prev.title}
+ + +
下一篇
+
{next.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..031feb24 --- /dev/null +++ b/themes/heo/components/ArticleRecommend.js @@ -0,0 +1,70 @@ +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..0b092fb2 --- /dev/null +++ b/themes/heo/components/BlogPostArchive.js @@ -0,0 +1,86 @@ +import React from 'react' +import Link from 'next/link' +import BLOG from '@/blog.config' +import CONFIG from '../config' +import TagItemMini from './TagItemMini' +/** + * 博客归档列表 + * @param posts 所有文章 + * @param archiveTitle 归档标题 + * @returns {JSX.Element} + * @constructor + */ +const BlogPostArchive = ({ posts = [], archiveTitle, siteInfo }) => { + if (!posts || posts.length === 0) { + return <> + } else { + return ( +
+
+ {archiveTitle} +
+
    + {posts?.map(post => { + const showPreview = CONFIG.POST_LIST_PREVIEW && post.blockMap + if (post && !post.pageCoverThumbnail && CONFIG.POST_LIST_COVER_DEFAULT) { + post.pageCoverThumbnail = siteInfo?.pageCover + } + const showPageCover = CONFIG.POST_LIST_COVER && post?.pageCoverThumbnail && !showPreview + return
    + + {/* 图片封面 */} + {showPageCover && ( +
    + +
    + +
    + )} + + {/* 文字区块 */} +
    +
    + {/* 分类 */} + {post?.category && } + + {/* 标题 */} + + {post.title} + +
    + + {/* 摘要 */} +

    + {post.summary} +

    + +
    +
    + {' '} + {post.tagItems?.map(tag => ( + + ))} +
    +
    + +
    +
    + })} +
+
+ ) + } +} + +export default BlogPostArchive diff --git a/themes/heo/components/BlogPostCard.js b/themes/heo/components/BlogPostCard.js new file mode 100644 index 00000000..d89aab5c --- /dev/null +++ b/themes/heo/components/BlogPostCard.js @@ -0,0 +1,79 @@ +import Link from 'next/link' +import CONFIG from '../config' +import BLOG from '@/blog.config' +import TagItemMini from './TagItemMini' +// 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.pageCoverThumbnail = siteInfo?.pageCover + } + const showPageCover = CONFIG.POST_LIST_COVER && post?.pageCoverThumbnail && !showPreview + return ( +
+
+ + {/* 图片封面 */} + {showPageCover && ( +
+ +
+ +
+ )} + + {/* 文字区块 */} +
+
+ {/* 分类 */} + {post?.category && } + + {/* 标题 */} + + {post.title} + +
+ + {/* 摘要 */} + {(!showPreview || showSummary) && !post.results && ( +

+ {post.summary} +

+ )} + + {/* 搜索结果 */} + {post.results && ( +

+ {post.results.map(r => ( + {r} + ))} +

+ )} + +
+
+ {' '} + {post.tagItems?.map(tag => ( + + ))} +
+
+ +
+
+ +
+ + ) +} + +export default BlogPostCard 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..8c9e1fd6 --- /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..0859dec0 --- /dev/null +++ b/themes/heo/components/Card.js @@ -0,0 +1,9 @@ +const Card = ({ children, headerSlot, className }) => { + return
+ <>{headerSlot} +
+ {children} +
+
+} +export default Card diff --git a/themes/heo/components/Catalog.js b/themes/heo/components/Catalog.js new file mode 100644 index 00000000..115ddd0c --- /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/CategoryBar.js b/themes/heo/components/CategoryBar.js new file mode 100644 index 00000000..31ee3260 --- /dev/null +++ b/themes/heo/components/CategoryBar.js @@ -0,0 +1,41 @@ +import { useGlobal } from '@/lib/global' +import Link from 'next/link' +import { useRouter } from 'next/router' + +/** + * 博客列表上方嵌入条 + * @param {*} props + * @returns + */ +export default function CategoryBar(props) { + const { categoryOptions, border = true } = props + const { locale } = useGlobal() + + return
+ +
+ + {categoryOptions?.map((c, index) => )} +
+ +
+ + {locale.COMMON.MORE} + +
+
+} + +/** + * 按钮 + * @param {*} param0 + * @returns + */ +const MenuItem = ({ href, name }) => { + const router = useRouter() + const selected = router.pathname === href + return
+ {name} +
+} diff --git a/themes/heo/components/CategoryGroup.js b/themes/heo/components/CategoryGroup.js new file mode 100644 index 00000000..811ad9ff --- /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..749d6048 --- /dev/null +++ b/themes/heo/components/Footer.js @@ -0,0 +1,54 @@ +import React from 'react' +import BLOG from '@/blog.config' +import SocialButton from './SocialButton' +// 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/Hero.js b/themes/heo/components/Hero.js new file mode 100644 index 00000000..d34530c2 --- /dev/null +++ b/themes/heo/components/Hero.js @@ -0,0 +1,251 @@ +// import Image from 'next/image' + +import BLOG from '@/blog.config' +import { ArrowSmallRight, PlusSmall } from '@/components/HeroIcons' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useImperativeHandle, useRef, useState } from 'react' +import CONFIG from '../config' +import { useGlobal } from '@/lib/global' +import { Transition } from '@headlessui/react' + +/** + * 顶部英雄区 + * 左右布局, + * 左侧:banner组 + * 右侧:今日卡牌遮罩 + * @returns + */ +const Hero = props => { + const { onLoading } = useGlobal() + return ( +
+ + + {/* 左侧banner组 */} + + + {/* 右侧置顶文章组 */} + + + +
+ ) +} + +/** + * 英雄区左侧banner组 + * @returns + */ +function BannerGroup(props) { + return ( + // 左侧英雄区 +
+ {/* 动图 */} + + {/* 导航分类 */} + +
+ ) +} + +/** + * 英雄区左上角banner动图 + * @returns + */ +function Banner(props) { + const router = useRouter() + const { latestPosts } = props + // 跳转到任意文章 + function handleClickBanner() { + const randomIndex = Math.floor(Math.random() * latestPosts.length) + const randomPost = latestPosts[randomIndex] + router.push(randomPost.slug) + } + + return
+ + + + {/* 斜向滚动的图标 */} + + + {/* 遮罩 */} + + +
+} + +/** + * 图标滚动标签组 + * 英雄区左上角banner条中斜向滚动的图标 + */ +function TagsGroupBar() { + const groupIcons = CONFIG.GROUP_ICONS.concat(CONFIG.GROUP_ICONS) + return ( +
+
+ {groupIcons?.map(g => { + return (<> +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+ ) + })} +
+
+ ) +} + +/** + * 英雄区左下角3个分类按钮 + * @returns + */ +function GroupMenu() { + return ( +
+ +
+ 必看精选 + +
+
+ +
+ + +
+ 热门文章 + +
+
+ +
+ + {/* 第三个标签在小屏上不显示 */} + +
+ 实用教程 + +
+
+ +
+ + +
+ ) +} + +/** + * 置顶文章区域 + */ +function TopGroup(props) { + const { latestPosts, siteInfo } = props + const todayCardRef = useRef() + function handleMouseLeave() { + todayCardRef.current.coverUp() + } + return ( +
+ {/* 制定最新文章 */} +
+ {latestPosts?.map(p => { + return +
+ {/* eslint-disable-next-line */} + +
{p?.title}
+ {/* hover 悬浮的 ‘荐’ 字 */} +
+ 荐 +
+
+ + })} +
+ +
+ ) +} + +/** + * 英雄区右侧,今日卡牌 + * @returns + */ +function TodayCard({ cRef }) { + const router = useRouter() + // 卡牌是否盖住下层 + const [isCoverUp, setIsCoverUp] = useState(true) + + /** + * 外部可以调用此方法 + */ + useImperativeHandle(cRef, () => { + return { + coverUp: () => { + setIsCoverUp(true) + } + } + }) + + /** + * 点击更多 + * @param {*} e + */ + function handleClickMore(e) { + e.stopPropagation() + setIsCoverUp(false) + } + + /** + * 点击卡片跳转的链接 + * @param {*} e + */ + function handleCardClick(e) { + router.push('https://tangly1024.com') + } + + return +} + +export default Hero 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..9dd4e1e9 --- /dev/null +++ b/themes/heo/components/InfoCard.js @@ -0,0 +1,87 @@ +import BLOG from '@/blog.config' +import { ArrowRightCircle, GlobeAlt } from '@/components/HeroIcons' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useState } from 'react' +import CONFIG from '../config' +import Announcement from './Announcement' +import Card from './Card' + +/** + * 社交信息卡 + * @param {*} props + * @returns + */ +export function InfoCard(props) { + const { siteInfo, notice } = props + const router = useRouter() + // 在文章详情页特殊处理 + const isSlugPage = router.pathname === '/[...slug]' + + return ( + + {/* 信息卡牌第一行 */} +
+ {/* 问候语 */} + +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {BLOG.AUTHOR} +
+
+ +

+ {BLOG.AUTHOR} +

+ + {/* 公告栏 */} +
+ +
+ +
+
+ {/* 两个社交按钮 */} +
+ +
+
+ + +
+
+ +
+
+ ) +} + +/** + * 欢迎语 + */ +function GreetingsWords() { + const greetings = CONFIG.INFOCARD_GREETINGS + const [greeting, setGreeting] = useState(greetings[0]) + // 每次点击,随机获取greetings中的一个 + const handleChangeGreeting = () => { + const randomIndex = Math.floor(Math.random() * greetings.length) + setGreeting(greetings[randomIndex]) + } + + return
+ {greeting} +
+} + +/** + * 了解更多按鈕 + * @returns + */ +function MoreButton() { + return +
+ +
了解更多
+
+ +} 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..80f0363f --- /dev/null +++ b/themes/heo/components/LatestPostsGroup.js @@ -0,0 +1,50 @@ +import BLOG from '@/blog.config' +// import Image from 'next/image' +import Link from 'next/link' + +/** + * 最新文章列表 + * @param posts 所有文章数据 + * @param sliceCount 截取展示的数量 默认6 + * @constructor + */ +const LatestPostsGroup = ({ latestPosts, siteInfo }) => { + // 获取当前路径 + + if (!latestPosts) { + return <> + } + + return
+ {latestPosts.map(post => { + const headerImage = post?.pageCoverThumbnail ? post.pageCoverThumbnail : siteInfo?.pageCover + + return ( + ( + +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ +
+ +
{post.title}
+ +
+ + ) + ) + })} +
+} +export default LatestPostsGroup diff --git a/themes/heo/components/LatestPostsGroupMini.js b/themes/heo/components/LatestPostsGroupMini.js new file mode 100644 index 00000000..d69662a3 --- /dev/null +++ b/themes/heo/components/LatestPostsGroupMini.js @@ -0,0 +1,63 @@ +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 + */ +export default function LatestPostsGroupMini ({ 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}
+
+
+ + ) + ) + })} + +} 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..a57cea2e --- /dev/null +++ b/themes/heo/components/Logo.js @@ -0,0 +1,25 @@ +import BLOG from '@/blog.config' +import { Home } from '@/components/HeroIcons' +import Link from 'next/link' +import React from 'react' + +const Logo = props => { + const { siteInfo } = props + return ( + +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {BLOG.AUTHOR} +
+
+ {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..89591369 --- /dev/null +++ b/themes/heo/components/MenuGroupCard.js @@ -0,0 +1,44 @@ +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..fe8fdf53 --- /dev/null +++ b/themes/heo/components/MenuItemDrop.js @@ -0,0 +1,42 @@ +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/NavBar.js b/themes/heo/components/NavBar.js new file mode 100644 index 00000000..297df23b --- /dev/null +++ b/themes/heo/components/NavBar.js @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import Logo from './Logo' + +import { MenuListTop } from './MenuListTop' +import throttle from 'lodash.throttle' +import RandomPostButton from './RandomPostButton' +import SearchButton from './SearchButton' +import SlideOver from './SlideOver' +import ReadingProgress from './ReadingProgress' +/** + * 顶部导航 + * @param {*} param0 + * @returns + */ +const NavBar = props => { + const [fixedNav, setFixedNav] = useState(false) + const [textWhite, setTextWhite] = useState(false) + const [navBgWhite, setBgWhite] = useState(false) + const slideOverRef = useRef() + + const toggleMenuOpen = () => { + slideOverRef?.current?.toggleSlideOvers() + } + + // 监听滚动 + useEffect(() => { + scrollTrigger() + window.addEventListener('scroll', scrollTrigger) + return () => { + window.removeEventListener('scroll', scrollTrigger) + } + }, []) + + const throttleMs = 200 + + /** + * 根据滚动条,切换导航栏样式 + */ + const scrollTrigger = useCallback(throttle(() => { + const scrollS = window.scrollY + + // 导航栏设置 白色背景 + if (scrollS <= 0) { + setFixedNav(false) + setBgWhite(false) + + // 文章详情页特殊处理 + if (document.querySelector('#post-bg')) { + setFixedNav(true) + setTextWhite(true) + setBgWhite(false) + } + } else { + // 向下滚动后的导航样式 + setFixedNav(true) + setTextWhite(false) + setBgWhite(true) + } + }, throttleMs)) + + return (<> + {/* 顶部导航菜单栏 */} + + ) +} + +export default NavBar 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/NoticeBar.js b/themes/heo/components/NoticeBar.js new file mode 100644 index 00000000..b7ec3f3b --- /dev/null +++ b/themes/heo/components/NoticeBar.js @@ -0,0 +1,37 @@ + +import { ArrowRightCircle } from '@/components/HeroIcons' +import CONFIG from '../config' +import Swipe from './Swipe' + +/** + * 通知横幅 + */ +export function NoticeBar() { + const notices = CONFIG.NOTICE_BAR + + if (!notices || notices?.length === 0) { + return <> + } + + return ( + +
+ 此刻 +
+ +
+
+
+
+ ) +} + +/** + * {notices?.map((n, index) => { + return + {n.category} +
{n.title}
+
+ + })} + */ diff --git a/themes/heo/components/PaginationNumber.js b/themes/heo/components/PaginationNumber.js new file mode 100644 index 00000000..1b96b42a --- /dev/null +++ b/themes/heo/components/PaginationNumber.js @@ -0,0 +1,160 @@ +import { ChevronDoubleRight } from '@/components/HeroIcons' +import { useGlobal } from '@/lib/global' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useState } from 'react' + +/** + * 数字翻页插件 + * @param page 当前页码 + * @param showNext 是否有下一页 + * @returns {JSX.Element} + * @constructor + */ +const PaginationNumber = ({ page, totalPage }) => { + const router = useRouter() + const { locale } = useGlobal() + 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) + + const [value, setValue] = useState('') + + const handleInputChange = (event) => { + const newValue = event.target.value.replace(/[^0-9]/g, '') + setValue(newValue) + } + + /** + * 调到指定页 + */ + const jumpToPage = () => { + if (value) { + router.push(value === 1 ? `${pagePrefix}/` : `${pagePrefix}/page/${value}`) + } + } + + return ( +
+ {/* 上一页 */} + +
+ +
+ {locale.PAGINATION.PREV} +
+
+ + + + {/* 分页 */} +
+ {pages} + + {/* 跳转页码 */} +
+ +
+ +
+
+
+ + {/* 下一页 */} + + +
+ +
+ {locale.PAGINATION.NEXT} +
+
+ +
+ ) +} + +/** + * 页码按钮 + * @param {*} page + * @param {*} currentPage + * @param {*} pagePrefix + * @returns + */ +function getPageElement(page, currentPage, pagePrefix) { + const selected = page + '' === currentPage + '' + return ( + ( + + {page} + + ) + ) +} + +/** + * 获取所有页码 + * @param {*} pagePrefix + * @param {*} page + * @param {*} currentPage + * @param {*} totalPage + * @returns + */ +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/PostHeader.js b/themes/heo/components/PostHeader.js new file mode 100644 index 00000000..9eab504e --- /dev/null +++ b/themes/heo/components/PostHeader.js @@ -0,0 +1,100 @@ +import Link from 'next/link' +import BLOG from '@/blog.config' +import NotionIcon from '@/components/NotionIcon' +import WavesArea from './WavesArea' +import { HashTag } from '@/components/HeroIcons' +import WordCount from '@/components/WordCount' + +export default function PostHeader({ post, siteInfo }) { + if (!post) { + return <> + } + // 文章头图 + const headerImage = post?.pageCover ? post.pageCover : siteInfo?.pageCover + + return ( +
+ + +
+ + {/* 文章背景图 */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + {/* 文章文字描述 */} +
+ {/* 分类+标签 */} +
+ {post.category && <> + +
+ {post.category} +
+ + } + + {post.tagItems && ( +
+ {post.tagItems.map(tag => ( + +
{tag.name + (tag.count ? `(${tag.count})` : '')}
+ + + ))} +
+ )} +
+ + {/* 文章Title */} +
+ {post.title} +
+ + {/* 标题底部补充信息 */} +
+ +
+
+ {post?.type !== 'Page' && ( + <> + + {post?.publishTime} + + + )} + +
+ + {BLOG.ANALYTICS_BUSUANZI_ENABLE &&
+ +
} +
+ +
+ + + +
+
+ ) +} 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/RandomPostButton.js b/themes/heo/components/RandomPostButton.js new file mode 100644 index 00000000..66d6cc85 --- /dev/null +++ b/themes/heo/components/RandomPostButton.js @@ -0,0 +1,19 @@ +import { useRouter } from 'next/router' + +/** + * 随机跳转到一个文章 + */ +export default function RandomPostButton(props) { + const { latestPosts } = props + const router = useRouter() + function handleClick() { + const randomIndex = Math.floor(Math.random() * latestPosts.length) + const randomPost = latestPosts[randomIndex] + router.push(randomPost.slug) + } + return ( +
+ +
+ ) +} diff --git a/themes/heo/components/ReadingProgress.js b/themes/heo/components/ReadingProgress.js new file mode 100644 index 00000000..fed74a54 --- /dev/null +++ b/themes/heo/components/ReadingProgress.js @@ -0,0 +1,42 @@ +import { ArrowSmallUp } from '@/components/HeroIcons' +import { useEffect, useState } from 'react' + +/** + * 回顶按钮 + * @returns + */ +export default function ReadingProgress() { + const [scrollPercentage, setScrollPercentage] = useState(0) + useEffect(() => { + let requestId + + function handleScroll() { + const scrollHeight = document.documentElement.scrollHeight + const clientHeight = document.documentElement.clientHeight + const scrollY = window.scrollY || window.pageYOffset + + const percent = Math.floor((scrollY / (scrollHeight - clientHeight)) * 100) + setScrollPercentage(percent) + + requestId = requestAnimationFrame(handleScroll) + } + + handleScroll() // 初始化滚动位置 + + return () => { + cancelAnimationFrame(requestId) + } + }, []) + + return (<> +
window.scrollTo({ top: 0, behavior: 'smooth' })} + className={`${scrollPercentage > 0 ? 'w-10 h-10 ' : 'w-0 h-0 opacity-0'} group cursor-pointer hover:bg-black hover:bg-opacity-10 rounded-full flex justify-center items-center duration-200 transition-all`} > +
+ + ) +} 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 ( +
+
+ + {floatSlot} + +
+
+ ) +} diff --git a/themes/heo/components/SearchButton.js b/themes/heo/components/SearchButton.js new file mode 100644 index 00000000..137d6c95 --- /dev/null +++ b/themes/heo/components/SearchButton.js @@ -0,0 +1,7 @@ +import Link from 'next/link' + +export default function SearchButton() { + 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 ( +
+
+
+ + {slot} +
+
+ + {/* 背景蒙版 */} +
+
+ ) +} + +export default SearchDrawer diff --git a/themes/heo/components/SearchInput.js b/themes/heo/components/SearchInput.js new file mode 100644 index 00000000..6e577bba --- /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..7be2b8ce --- /dev/null +++ b/themes/heo/components/SearchNav.js @@ -0,0 +1,68 @@ +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}: +
+
+ {tagOptions?.map(tag => { + return ( +
+ +
+ ) + })} +
+
+
+ +} 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 ( + + ) +} + +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