From f7ceb2f9ec2c7194e75ebc3208a0b5c5e2ab452c Mon Sep 17 00:00:00 2001 From: BlackBerry009 <765042894@qq.com> Date: Sun, 8 Jun 2025 00:50:22 +0800 Subject: [PATCH] feat: new theme --- blog.config.js | 38 +- themes/typography/components/Announcement.js | 13 + themes/typography/components/ArticleAround.js | 32 ++ themes/typography/components/ArticleInfo.js | 65 ++++ themes/typography/components/ArticleLock.js | 52 +++ .../typography/components/BlogArchiveItem.js | 37 ++ themes/typography/components/BlogItem.js | 106 ++++++ themes/typography/components/BlogListPage.js | 74 ++++ .../typography/components/BlogListScroll.js | 70 ++++ themes/typography/components/BlogPostBar.js | 29 ++ themes/typography/components/Catalog.js | 97 +++++ .../components/ExampleRecentComments.js | 35 ++ themes/typography/components/Footer.js | 37 ++ themes/typography/components/Header.js | 54 +++ .../typography/components/JumpToTopButton.js | 35 ++ .../typography/components/MenuItemCollapse.js | 92 +++++ themes/typography/components/MenuItemDrop.js | 21 ++ themes/typography/components/MenuList.js | 84 +++++ themes/typography/components/NavBar.js | 33 ++ .../typography/components/RecommendPosts.js | 32 ++ themes/typography/components/SearchInput.js | 86 +++++ themes/typography/components/SocialButton.js | 115 ++++++ themes/typography/components/Title.js | 19 + themes/typography/components/TopBar.js | 19 + themes/typography/config.js | 22 ++ themes/typography/index.js | 357 ++++++++++++++++++ themes/typography/style.js | 84 +++++ 27 files changed, 1719 insertions(+), 19 deletions(-) create mode 100644 themes/typography/components/Announcement.js create mode 100644 themes/typography/components/ArticleAround.js create mode 100644 themes/typography/components/ArticleInfo.js create mode 100644 themes/typography/components/ArticleLock.js create mode 100644 themes/typography/components/BlogArchiveItem.js create mode 100644 themes/typography/components/BlogItem.js create mode 100644 themes/typography/components/BlogListPage.js create mode 100644 themes/typography/components/BlogListScroll.js create mode 100644 themes/typography/components/BlogPostBar.js create mode 100644 themes/typography/components/Catalog.js create mode 100644 themes/typography/components/ExampleRecentComments.js create mode 100644 themes/typography/components/Footer.js create mode 100644 themes/typography/components/Header.js create mode 100644 themes/typography/components/JumpToTopButton.js create mode 100644 themes/typography/components/MenuItemCollapse.js create mode 100644 themes/typography/components/MenuItemDrop.js create mode 100644 themes/typography/components/MenuList.js create mode 100644 themes/typography/components/NavBar.js create mode 100644 themes/typography/components/RecommendPosts.js create mode 100644 themes/typography/components/SearchInput.js create mode 100644 themes/typography/components/SocialButton.js create mode 100644 themes/typography/components/Title.js create mode 100644 themes/typography/components/TopBar.js create mode 100644 themes/typography/config.js create mode 100644 themes/typography/index.js create mode 100644 themes/typography/style.js diff --git a/blog.config.js b/blog.config.js index a8a972a7..81c91c5e 100644 --- a/blog.config.js +++ b/blog.config.js @@ -1,32 +1,32 @@ -// 注: process.env.XX是Vercel的环境变量,配置方式见:https://docs.tangly1024.com/article/how-to-config-notion-next#c4768010ae7d44609b744e79e2f9959a +// 注:process.env.XX 是 Vercel 的环境变量,配置方式见:https://docs.tangly1024.com/article/how-to-config-notion-next#c4768010ae7d44609b744e79e2f9959a const BLOG = { // Important page_id!!!Duplicate Template from https://www.notion.so/tanghh/02ab3b8678004aa69e9e415905ef32a5 NOTION_PAGE_ID: process.env.NOTION_PAGE_ID || '02ab3b8678004aa69e9e415905ef32a5,en:7c1d570661754c8fbc568e00a01fd70e', - THEME: process.env.NEXT_PUBLIC_THEME || 'simple', // 当前主题,在themes文件夹下可找到所有支持的主题;主题名称就是文件夹名,例如 example,fukasawa,gitbook,heo,hexo,landing,matery,medium,next,nobelium,plog,simple + THEME: process.env.NEXT_PUBLIC_THEME || 'typography', // 当前主题,在 themes 文件夹下可找到所有支持的主题;主题名称就是文件夹名,例如 example,fukasawa,gitbook,heo,hexo,landing,matery,medium,next,nobelium,plog,simple LANG: process.env.NEXT_PUBLIC_LANG || 'zh-CN', // e.g 'zh-CN','en-US' see /lib/lang.js for more. SINCE: process.env.NEXT_PUBLIC_SINCE || 2021, // e.g if leave this empty, current year will be used. - PSEUDO_STATIC: process.env.NEXT_PUBLIC_PSEUDO_STATIC || false, // 伪静态路径,开启后所有文章URL都以 .html 结尾。 - NEXT_REVALIDATE_SECOND: process.env.NEXT_PUBLIC_REVALIDATE_SECOND || 5, // 更新缓存间隔 单位(秒);即每个页面有5秒的纯静态期、此期间无论多少次访问都不会抓取notion数据;调大该值有助于节省Vercel资源、同时提升访问速率,但也会使文章更新有延迟。 - APPEARANCE: process.env.NEXT_PUBLIC_APPEARANCE || 'light', // ['light', 'dark', 'auto'], // light 日间模式 , dark夜间模式, auto根据时间和主题自动夜间模式 - APPEARANCE_DARK_TIME: process.env.NEXT_PUBLIC_APPEARANCE_DARK_TIME || [18, 6], // 夜间模式起至时间,false时关闭根据时间自动切换夜间模式 + PSEUDO_STATIC: process.env.NEXT_PUBLIC_PSEUDO_STATIC || false, // 伪静态路径,开启后所有文章 URL 都以 .html 结尾。 + NEXT_REVALIDATE_SECOND: process.env.NEXT_PUBLIC_REVALIDATE_SECOND || 5, // 更新缓存间隔 单位 (秒);即每个页面有 5 秒的纯静态期、此期间无论多少次访问都不会抓取 notion 数据;调大该值有助于节省 Vercel 资源、同时提升访问速率,但也会使文章更新有延迟。 + APPEARANCE: process.env.NEXT_PUBLIC_APPEARANCE || 'light', // ['light', 'dark', 'auto'], // light 日间模式,dark 夜间模式,auto 根据时间和主题自动夜间模式 + APPEARANCE_DARK_TIME: process.env.NEXT_PUBLIC_APPEARANCE_DARK_TIME || [18, 6], // 夜间模式起至时间,false 时关闭根据时间自动切换夜间模式 AUTHOR: process.env.NEXT_PUBLIC_AUTHOR || 'NotionNext', // 您的昵称 例如 tangly1024 BIO: process.env.NEXT_PUBLIC_BIO || '一个普通的干饭人🍚', // 作者简介 LINK: process.env.NEXT_PUBLIC_LINK || 'https://tangly1024.com', // 网站地址 KEYWORDS: process.env.NEXT_PUBLIC_KEYWORD || 'Notion, 博客', // 网站关键词 英文逗号隔开 - BLOG_FAVICON: process.env.NEXT_PUBLIC_FAVICON || '/favicon.ico', // blog favicon 配置, 默认使用 /public/favicon.ico,支持在线图片,如 https://img.imesong.com/favicon.png - BEI_AN: process.env.NEXT_PUBLIC_BEI_AN || '', // 备案号 闽ICP备XXXXXX + BLOG_FAVICON: process.env.NEXT_PUBLIC_FAVICON || '/favicon.ico', // blog favicon 配置,默认使用 /public/favicon.ico,支持在线图片,如 https://img.imesong.com/favicon.png + BEI_AN: process.env.NEXT_PUBLIC_BEI_AN || '', // 备案号 闽 ICP 备 XXXXXX BEI_AN_LINK: process.env.NEXT_PUBLIC_BEI_AN_LINK || 'https://beian.miit.gov.cn/', // 备案查询链接,如果用了萌备等备案请在这里填写 - // RSS订阅 - ENABLE_RSS: process.env.NEXT_PUBLIC_ENABLE_RSS || true, // 是否开启RSS订阅功能 + // RSS 订阅 + ENABLE_RSS: process.env.NEXT_PUBLIC_ENABLE_RSS || true, // 是否开启 RSS 订阅功能 // 其它复杂配置 - // 原配置文件过长,且并非所有人都会用到,故此将配置拆分到/conf/目录下, 按需找到对应文件并修改即可 + // 原配置文件过长,且并非所有人都会用到,故此将配置拆分到/conf/目录下,按需找到对应文件并修改即可 ...require('./conf/comment.config'), // 评论插件 ...require('./conf/contact.config'), // 作者联系方式配置 ...require('./conf/post.config'), // 文章与列表配置 @@ -38,11 +38,11 @@ const BLOG = { ...require('./conf/animation.config'), // 动效美化效果 ...require('./conf/widget.config'), // 悬浮在网页上的挂件,聊天客服、宠物挂件、音乐播放器等 ...require('./conf/ad.config'), // 广告营收插件 - ...require('./conf/plugin.config'), // 其他第三方插件 algolia全文索引 + ...require('./conf/plugin.config'), // 其他第三方插件 algolia 全文索引 // 高级用法 ...require('./conf/layout-map.config'), // 路由与布局映射自定义,例如自定义特定路由的页面布局 - ...require('./conf/notion.config'), // 读取notion数据库相关的扩展配置,例如自定义表头 + ...require('./conf/notion.config'), // 读取 notion 数据库相关的扩展配置,例如自定义表头 ...require('./conf/dev.config'), // 开发、调试时需要关注的配置 // 自定义外部脚本,外部样式 @@ -50,21 +50,21 @@ const BLOG = { CUSTOM_EXTERNAL_CSS: [''], // e.g. ['http://xx.com/style.css','http://xx.com/style.css'] // 自定义菜单 - CUSTOM_MENU: process.env.NEXT_PUBLIC_CUSTOM_MENU || true, // 支持Menu类型的菜单,替代了3.12版本前的Page类型 + CUSTOM_MENU: process.env.NEXT_PUBLIC_CUSTOM_MENU || true, // 支持 Menu 类型的菜单,替代了 3.12 版本前的 Page 类型 // 文章列表相关设置 - CAN_COPY: process.env.NEXT_PUBLIC_CAN_COPY || true, // 是否允许复制页面内容 默认允许,如果设置为false、则全栈禁止复制内容。 + CAN_COPY: process.env.NEXT_PUBLIC_CAN_COPY || true, // 是否允许复制页面内容 默认允许,如果设置为 false、则全栈禁止复制内容。 - // 侧栏布局 是否反转(左变右,右变左) 已支持主题: hexo next medium fukasawa example + // 侧栏布局 是否反转 (左变右,右变左) 已支持主题:hexo next medium fukasawa example LAYOUT_SIDEBAR_REVERSE: process.env.NEXT_PUBLIC_LAYOUT_SIDEBAR_REVERSE || false, - // 欢迎语打字效果,Hexo,Matery主题支持, 英文逗号隔开多个欢迎语。 + // 欢迎语打字效果,Hexo,Matery 主题支持,英文逗号隔开多个欢迎语。 GREETING_WORDS: process.env.NEXT_PUBLIC_GREETING_WORDS || - 'Hi,我是一个程序员, Hi,我是一个打工人,Hi,我是一个干饭人,欢迎来到我的博客🎉', + 'Hi,我是一个程序员,Hi,我是一个打工人,Hi,我是一个干饭人,欢迎来到我的博客🎉', - // uuid重定向至 slug + // uuid 重定向至 slug UUID_REDIRECT: process.env.UUID_REDIRECT || false } diff --git a/themes/typography/components/Announcement.js b/themes/typography/components/Announcement.js new file mode 100644 index 00000000..13d069d8 --- /dev/null +++ b/themes/typography/components/Announcement.js @@ -0,0 +1,13 @@ +import dynamic from 'next/dynamic' + +const NotionPage = dynamic(() => import('@/components/NotionPage')) + +const Announcement = ({ post, className }) => { + if (!post) { + return <> + } + return <>{post && (
+ +
)} +} +export default Announcement diff --git a/themes/typography/components/ArticleAround.js b/themes/typography/components/ArticleAround.js new file mode 100644 index 00000000..b4cee232 --- /dev/null +++ b/themes/typography/components/ArticleAround.js @@ -0,0 +1,32 @@ +import Link from 'next/link' + +/** + * 上一篇,下一篇文章 + * @param {prev,next} param0 + * @returns + */ +export default function ArticleAround({ prev, next }) { + if (!prev || !next) { + return <> + } + return ( +
+ {prev && + + {prev.title} + + } + {next && + {next.title} + + + } +
+ ) +} diff --git a/themes/typography/components/ArticleInfo.js b/themes/typography/components/ArticleInfo.js new file mode 100644 index 00000000..ed1d39a9 --- /dev/null +++ b/themes/typography/components/ArticleInfo.js @@ -0,0 +1,65 @@ +import Link from 'next/link' +import { useGlobal } from '@/lib/global' +import CONFIG from '../config' +import { siteConfig } from '@/lib/config' +import { formatDateFmt } from '@/lib/utils/formatDate' +import NotionIcon from '@/components/NotionIcon' + +/** + * 文章描述 + * @param {*} props + * @returns + */ +export default function ArticleInfo(props) { + const { post } = props + + const { locale } = useGlobal() + + return ( +
+

+ {siteConfig('POST_TITLE_ICON') && } + {post?.title} +

+ +
+ {post?.type !== 'Page' && ( +
+
+ + 发布于 + + {post.date?.start_date || post.createdTime} + + +
+ +
+ {/* {post.category && ( + + {' '} + + + {post.category} + + + )} */} + {post?.tags && + post?.tags?.length > 0 && + post?.tags.map(t => ( + + #{t} + + ))} +
+
+ )} +
+
+ ) +} diff --git a/themes/typography/components/ArticleLock.js b/themes/typography/components/ArticleLock.js new file mode 100644 index 00000000..d9f6e978 --- /dev/null +++ b/themes/typography/components/ArticleLock.js @@ -0,0 +1,52 @@ +import { useGlobal } from '@/lib/global' +import { useEffect, useRef } from 'react' + +/** + * 加密文章校验组件 + * @param {password, validPassword} props + * @param password 正确的密码 + * @param validPassword(bool) 回调函数,校验正确回调入参为true + * @returns + */ +export default function ArticleLock (props) { + const { validPassword } = props + const { locale } = useGlobal() + + const submitPassword = () => { + const p = document.getElementById('password') + if (!validPassword(p?.value)) { + const tips = document.getElementById('tips') + if (tips) { + tips.innerHTML = '' + tips.innerHTML = `
${locale.COMMON.PASSWORD_ERROR}
` + } + } + } + const passwordInputRef = useRef(null) + useEffect(() => { + // 选中密码输入框并将其聚焦 + passwordInputRef.current.focus() + }, []) + + return
+
+
{locale.COMMON.ARTICLE_LOCK_TIPS}
+
+ { + if (e.key === 'Enter') { + submitPassword() + } + }} + ref={passwordInputRef} // 绑定ref到passwordInputRef变量 + className='outline-none w-full text-sm pl-5 rounded-l transition focus:shadow-lg font-light leading-10 text-black dark:bg-gray-500 bg-gray-50' + > +
+  {locale.COMMON.SUBMIT} +
+
+
+
+
+
+} diff --git a/themes/typography/components/BlogArchiveItem.js b/themes/typography/components/BlogArchiveItem.js new file mode 100644 index 00000000..4a92959e --- /dev/null +++ b/themes/typography/components/BlogArchiveItem.js @@ -0,0 +1,37 @@ +import Link from 'next/link' + +/** + * 归档分组文章 + * @param {*} param0 + * @returns + */ +export default function BlogArchiveItem({ archiveTitle, archivePosts }) { + return ( +
+
+ {archiveTitle} +
+ +
    + {archivePosts[archiveTitle].map(post => { + return ( +
  • +
    + {post.date?.start_date}{' '} +   + + {post.title} + +
    +
  • + ) + })} +
+
+ ) +} diff --git a/themes/typography/components/BlogItem.js b/themes/typography/components/BlogItem.js new file mode 100644 index 00000000..b880f1fe --- /dev/null +++ b/themes/typography/components/BlogItem.js @@ -0,0 +1,106 @@ +import LazyImage from '@/components/LazyImage' +import NotionIcon from '@/components/NotionIcon' +import NotionPage from '@/components/NotionPage' +import TwikooCommentCount from '@/components/TwikooCommentCount' +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import { formatDateFmt } from '@/lib/utils/formatDate' +import Link from 'next/link' +import CONFIG from '../config' + +export const BlogItem = props => { + const { post } = props + const { NOTION_CONFIG } = useGlobal() + const showPageCover = siteConfig('SIMPLE_POST_COVER_ENABLE', false, CONFIG) + const showPreview = + siteConfig('POST_LIST_PREVIEW', false, NOTION_CONFIG) && post.blockMap + console.log(post); + return ( +
+ {/* 文章标题 */} + +
+
+ {/* 图片封面 */} + {showPageCover && ( +
+ + + +
+ )} +
+ +
+

+ + {siteConfig('POST_TITLE_ICON') && ( + + )} + {post.title} + +

+ + {/* 文章信息 */} +
+
+ + 发布于 + + {post.date?.start_date || post.createdTime} + + +
+ +
+ {/* {post.category && ( + + {' '} + + + {post.category} + + + )} */} + {post?.tags && + post?.tags?.length > 0 && + post?.tags.map(t => ( + + #{t} + + ))} +
+
+ +
+ {!showPreview && ( + <> + {post.summary} + {post.summary && ...} + + )} + {showPreview && post?.blockMap && ( +
+ +
+
+ )} +
+
+
+ +
+ ) +} diff --git a/themes/typography/components/BlogListPage.js b/themes/typography/components/BlogListPage.js new file mode 100644 index 00000000..ab245ed4 --- /dev/null +++ b/themes/typography/components/BlogListPage.js @@ -0,0 +1,74 @@ +import { AdSlot } from '@/components/GoogleAdsense' +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import Link from 'next/link' +import { useRouter } from 'next/router' +import CONFIG from '../config' +import { BlogItem } from './BlogItem' + +/** + * 博客列表 + * @param {*} props + * @returns + */ +export default function BlogListPage(props) { + const { page = 1, posts, postCount } = props + const router = useRouter() + const { NOTION_CONFIG } = useGlobal() + const POSTS_PER_PAGE = siteConfig('POSTS_PER_PAGE', null, NOTION_CONFIG) + const totalPage = Math.ceil(postCount / POSTS_PER_PAGE) + const currentPage = +page + + // 博客列表嵌入广告 + const SIMPLE_POST_AD_ENABLE = siteConfig( + 'SIMPLE_POST_AD_ENABLE', + false, + CONFIG + ) + + const showPrev = currentPage > 1 + const showNext = page < totalPage + const pagePrefix = router.asPath + .split('?')[0] + .replace(/\/page\/[1-9]\d*/, '') + .replace(/\/$/, '') + .replace('.html', '') + + return ( +
+
+ {posts?.map((p, index) => ( +
+ {SIMPLE_POST_AD_ENABLE && (index + 1) % 3 === 0 && ( + + )} + {SIMPLE_POST_AD_ENABLE && index + 1 === 4 && } + +
+ ))} +
+ +
+ + NEWER POSTS + + + OLDER POSTS + +
+
+ ) +} diff --git a/themes/typography/components/BlogListScroll.js b/themes/typography/components/BlogListScroll.js new file mode 100644 index 00000000..2252a995 --- /dev/null +++ b/themes/typography/components/BlogListScroll.js @@ -0,0 +1,70 @@ +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import throttle from 'lodash.throttle' +import { useCallback, useEffect, useRef, useState } from 'react' +import { BlogItem } from './BlogItem' + +/** + * 滚动博客列表 + * @param {*} props + * @returns + */ +export default function BlogListScroll(props) { + const { posts } = props + const { locale, NOTION_CONFIG } = useGlobal() + const [page, updatePage] = useState(1) + const POSTS_PER_PAGE = siteConfig('POSTS_PER_PAGE', null, NOTION_CONFIG) + let hasMore = false + const postsToShow = posts + ? Object.assign(posts).slice(0, POSTS_PER_PAGE * page) + : [] + + if (posts) { + const totalCount = posts.length + hasMore = page * POSTS_PER_PAGE < totalCount + } + const handleGetMore = () => { + if (!hasMore) return + updatePage(page + 1) + } + + const targetRef = useRef(null) + + // 监听滚动自动分页加载 + const scrollTrigger = useCallback( + throttle(() => { + const scrollS = window.scrollY + window.outerHeight + const clientHeight = targetRef + ? targetRef.current + ? targetRef.current.clientHeight + : 0 + : 0 + if (scrollS > clientHeight + 100) { + handleGetMore() + } + }, 500) + ) + + useEffect(() => { + window.addEventListener('scroll', scrollTrigger) + + return () => { + window.removeEventListener('scroll', scrollTrigger) + } + }) + + return ( +
+ {postsToShow.map(p => ( + + ))} + +
+ {' '} + {hasMore ? locale.COMMON.MORE : `${locale.COMMON.NO_MORE} 😰`}{' '} +
+
+ ) +} diff --git a/themes/typography/components/BlogPostBar.js b/themes/typography/components/BlogPostBar.js new file mode 100644 index 00000000..9efac113 --- /dev/null +++ b/themes/typography/components/BlogPostBar.js @@ -0,0 +1,29 @@ +import { useGlobal } from '@/lib/global' + +/** + * 文章列表上方嵌入 + * @param {*} props + * @returns + */ +export default function BlogPostBar(props) { + const { tag, category } = props + const { locale } = useGlobal() + + if (tag) { + return ( +
+ + {locale.COMMON.TAGS}: {tag} +
+ ) + } else if (category) { + return ( +
+ + {locale.COMMON.CATEGORY}: {category} +
+ ) + } else { + return <> + } +} diff --git a/themes/typography/components/Catalog.js b/themes/typography/components/Catalog.js new file mode 100644 index 00000000..6bea8bab --- /dev/null +++ b/themes/typography/components/Catalog.js @@ -0,0 +1,97 @@ +import { useGlobal } from '@/lib/global' +import throttle from 'lodash.throttle' +import { uuidToId } from 'notion-utils' +import { useEffect, useRef, useState } from 'react' + +/** + * 目录导航组件 + * @param toc + * @returns {JSX.Element} + * @constructor + */ +const Catalog = ({ post }) => { + const { locale } = useGlobal() + // 目录自动滚动 + const tRef = useRef(null) + // 同步选中目录事件 + const [activeSection, setActiveSection] = useState(null) + + // 监听滚动事件 + useEffect(() => { + const throttleMs = 200 + const actionSectionScrollSpy = 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) + if (bbox.top - offset < 0) { + currentSectionId = section.getAttribute('data-id') + prevBBox = bbox + continue + } + break + } + setActiveSection(currentSectionId) + const index = post?.toc?.findIndex( + obj => uuidToId(obj.id) === currentSectionId + ) + tRef?.current?.scrollTo({ top: 28 * index, behavior: 'smooth' }) + }, throttleMs) + + window.addEventListener('scroll', actionSectionScrollSpy) + actionSectionScrollSpy() + return () => { + window.removeEventListener('scroll', actionSectionScrollSpy) + } + }, [post]) + + // 无目录就直接返回空 + if (!post || !post?.toc || post?.toc?.length < 1) { + return <> + } + + return ( +
+
+ + {locale.COMMON.TABLE_OF_CONTENTS} +
+ +
+ +
+
+ ) +} + +export default Catalog diff --git a/themes/typography/components/ExampleRecentComments.js b/themes/typography/components/ExampleRecentComments.js new file mode 100644 index 00000000..93cde585 --- /dev/null +++ b/themes/typography/components/ExampleRecentComments.js @@ -0,0 +1,35 @@ +import Link from 'next/link' +import { RecentComments } from '@waline/client' +import { useEffect, useState } from 'react' +import { siteConfig } from '@/lib/config' + +/** + * @see https://waline.js.org/guide/get-started.html + * @param {*} props + * @returns + */ +const ExampleRecentComments = (props) => { + const [comments, updateComments] = useState([]) + const [onLoading, changeLoading] = useState(true) + useEffect(() => { + RecentComments({ + serverURL: siteConfig('COMMENT_WALINE_SERVER_URL'), + count: 5 + }).then(({ comments }) => { + changeLoading(false) + updateComments(comments) + }) + }, []) + + return <> + {onLoading &&
Loading...
} + {!onLoading && comments && comments.length === 0 &&
No Comments
} + {!onLoading && comments && comments.length > 0 && comments.map((comment) =>
+
+
--{comment.nick}
+
)} + + +} + +export default ExampleRecentComments diff --git a/themes/typography/components/Footer.js b/themes/typography/components/Footer.js new file mode 100644 index 00000000..a762960e --- /dev/null +++ b/themes/typography/components/Footer.js @@ -0,0 +1,37 @@ +import { BeiAnGongAn } from '@/components/BeiAnGongAn' +import DarkModeButton from '@/components/DarkModeButton' +import { siteConfig } from '@/lib/config' + +/** + * 页脚 + * @param {*} props + * @returns + */ +export default function Footer(props) { + const d = new Date() + const currentYear = d.getFullYear() + const since = siteConfig('SINCE') + const copyrightDate = + parseInt(since) < currentYear ? since + '-' + currentYear : currentYear + + return ( + + ) +} diff --git a/themes/typography/components/Header.js b/themes/typography/components/Header.js new file mode 100644 index 00000000..e5d71756 --- /dev/null +++ b/themes/typography/components/Header.js @@ -0,0 +1,54 @@ +import LazyImage from '@/components/LazyImage' +import { siteConfig } from '@/lib/config' +import Link from 'next/link' +import CONFIG from '../config' +import SocialButton from './SocialButton' + +/** + * 网站顶部 + * @returns + */ +export default function Header(props) { + const { siteInfo } = props + + return ( +
+
+ + {/* 可使用一张单图作为logo */} +
+
+ +
+ +
+
+ {siteConfig('AUTHOR')} +
+
+
+
+ + +
+ +
+
+ {siteConfig('DESCRIPTION')} +
+
+
+ ) +} diff --git a/themes/typography/components/JumpToTopButton.js b/themes/typography/components/JumpToTopButton.js new file mode 100644 index 00000000..358c9372 --- /dev/null +++ b/themes/typography/components/JumpToTopButton.js @@ -0,0 +1,35 @@ +import { useGlobal } from '@/lib/global' +import { useEffect, useState } from 'react' + +/** + * 跳转到网页顶部 + * 当屏幕下滑500像素后会出现该控件 + * @param targetRef 关联高度的目标html标签 + * @param showPercent 是否显示百分比 + * @returns {JSX.Element} + * @constructor + */ +const JumpToTopButton = () => { + const { locale } = useGlobal() + const [show, switchShow] = useState(false) + const scrollListener = () => { + const scrollY = window.pageYOffset + const shouldShow = scrollY > 200 + if (shouldShow !== show) { + switchShow(shouldShow) + } + } + + useEffect(() => { + document.addEventListener('scroll', scrollListener) + return () => document.removeEventListener('scroll', scrollListener) + }, [show]) + + return
window.scrollTo({ top: 0, behavior: 'smooth' })} + > +
+} + +export default JumpToTopButton diff --git a/themes/typography/components/MenuItemCollapse.js b/themes/typography/components/MenuItemCollapse.js new file mode 100644 index 00000000..50174dc3 --- /dev/null +++ b/themes/typography/components/MenuItemCollapse.js @@ -0,0 +1,92 @@ +import Collapse from '@/components/Collapse' +import Link from 'next/link' +import { useState } from 'react' + +/** + * 折叠菜单 + * @param {*} param0 + * @returns + */ +export const MenuItemCollapse = props => { + const { link } = props + const [show, changeShow] = useState(false) + const hasSubMenu = link?.subMenus?.length > 0 + + const [isOpen, changeIsOpen] = useState(false) + + const toggleShow = () => { + changeShow(!show) + } + + const toggleOpenSubMenu = () => { + changeIsOpen(!isOpen) + } + + if (!link || !link.show) { + return null + } + + return ( + <> +
+ {!hasSubMenu && ( + + + {link?.icon && ( + + + + )} + {link?.name} + + + )} + {hasSubMenu && ( +
+ + {link?.icon && ( + + + + )} + {link?.name} + + +
+ )} +
+ + {/* 折叠子菜单 */} + {hasSubMenu && ( + + {link.subMenus.map((sLink, index) => { + return ( +
+ + + {sLink?.icon && ( + + + + )} + {sLink.title} + + +
+ ) + })} +
+ )} + + ) +} diff --git a/themes/typography/components/MenuItemDrop.js b/themes/typography/components/MenuItemDrop.js new file mode 100644 index 00000000..6302e4f3 --- /dev/null +++ b/themes/typography/components/MenuItemDrop.js @@ -0,0 +1,21 @@ +import Link from 'next/link' +import { useState } from 'react' + +export const MenuItemDrop = ({ link }) => { + const hasSubMenu = link?.subMenus?.length > 0 + + if (!link || !link.show) { + return null + } + + return ( +
+ + {link?.name} + +
+ ) +} diff --git a/themes/typography/components/MenuList.js b/themes/typography/components/MenuList.js new file mode 100644 index 00000000..06f2bdf8 --- /dev/null +++ b/themes/typography/components/MenuList.js @@ -0,0 +1,84 @@ +import Collapse from '@/components/Collapse' +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import { useRouter } from 'next/router' +import { useEffect, useRef, useState } from 'react' +import CONFIG from '../config' +import { MenuItemCollapse } from './MenuItemCollapse' +import { MenuItemDrop } from './MenuItemDrop' + +/** + * 菜单导航 + * @param {*} props + * @returns + */ +export const MenuList = ({ customNav, customMenu }) => { + const { locale } = useGlobal() + const [isOpen, changeIsOpen] = useState(false) + const toggleIsOpen = () => { + changeIsOpen(!isOpen) + } + const closeMenu = e => { + changeIsOpen(false) + } + const router = useRouter() + const collapseRef = useRef(null) + + useEffect(() => { + router.events.on('routeChangeStart', closeMenu) + }) + + let links = [ + + { + icon: 'fas fa-archive', + name: locale.NAV.ARCHIVE, + href: '/archive', + show: siteConfig('SIMPLE_MENU_ARCHIVE', null, CONFIG) + }, + { + icon: 'fas fa-folder', + name: locale.COMMON.CATEGORY, + href: '/category', + show: siteConfig('SIMPLE_MENU_CATEGORY', null, CONFIG) + }, + { + icon: 'fas fa-tag', + name: locale.COMMON.TAGS, + href: '/tag', + show: siteConfig('SIMPLE_MENU_TAG', null, CONFIG) + } + ] + + if (customNav) { + links = links.concat(customNav) + } + + // 如果 开启自定义菜单,则覆盖 Page 生成的菜单 + if (siteConfig('CUSTOM_MENU')) { + links = customMenu + } + + if (!links || links.length === 0) { + return null + } + + return ( + <> + {/* 大屏模式菜单 - 垂直排列 */} + + {/* 移动端小屏菜单 - 水平排列 */} + + + ) +} diff --git a/themes/typography/components/NavBar.js b/themes/typography/components/NavBar.js new file mode 100644 index 00000000..314bedaa --- /dev/null +++ b/themes/typography/components/NavBar.js @@ -0,0 +1,33 @@ +import { siteConfig } from '@/lib/config' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { useSimpleGlobal } from '..' +import { MenuList } from './MenuList' +import SocialButton from './SocialButton' +import Link from 'next/link' + +/** + * 菜单导航 + * @param {*} props + * @returns + */ +export default function NavBar(props) { + return ( +
+
+ +
+
活版印字
+
Typography
+
+ +
+ +
+ ) +} diff --git a/themes/typography/components/RecommendPosts.js b/themes/typography/components/RecommendPosts.js new file mode 100644 index 00000000..fd214fa0 --- /dev/null +++ b/themes/typography/components/RecommendPosts.js @@ -0,0 +1,32 @@ +import Link from 'next/link' +import { useGlobal } from '@/lib/global' +import CONFIG from '../config' +import { siteConfig } from '@/lib/config' + +/** + * 展示文章推荐 + */ +const RecommendPosts = ({ recommendPosts }) => { + const { locale } = useGlobal() + if (!siteConfig('SIMPLE_ARTICLE_RECOMMEND_POSTS', null, CONFIG) || !recommendPosts || recommendPosts.length < 1) { + return <> + } + + return ( +
+
{locale.COMMON.RELATE_POSTS} :
+
    + {recommendPosts.map(post => ( +
  • + + + {post.title} + + +
  • + ))} +
+
+ ) +} +export default RecommendPosts diff --git a/themes/typography/components/SearchInput.js b/themes/typography/components/SearchInput.js new file mode 100644 index 00000000..f0b43a39 --- /dev/null +++ b/themes/typography/components/SearchInput.js @@ -0,0 +1,86 @@ +import { useRouter } from 'next/router' +import { useImperativeHandle, useRef, useState } from 'react' +let lock = false + +const SearchInput = ({ keyword, cRef, className }) => { + const [onLoading, setLoadingState] = useState(false) + const router = useRouter() + const searchInputRef = useRef() + useImperativeHandle(cRef, () => { + return { + focus: () => { + searchInputRef?.current?.focus() + } + } + }) + + const handleSearch = () => { + const key = searchInputRef.current.value + + if (key && key !== '') { + setLoadingState(true) + 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={keyword} + /> + +
+ +
+ + {(showClean && +
+ +
+ )} +
+} + +export default SearchInput diff --git a/themes/typography/components/SocialButton.js b/themes/typography/components/SocialButton.js new file mode 100644 index 00000000..ced5c850 --- /dev/null +++ b/themes/typography/components/SocialButton.js @@ -0,0 +1,115 @@ +import { siteConfig } from '@/lib/config' + +/** + * 社交联系方式按钮组 + * @returns {JSX.Element} + * @constructor + */ +const SocialButton = () => { + return ( +
+
+ {siteConfig('CONTACT_GITHUB') && ( + + + + )} + {siteConfig('CONTACT_TWITTER') && ( + + + + )} + {siteConfig('CONTACT_TELEGRAM') && ( + + + + )} + {siteConfig('CONTACT_LINKEDIN') && ( + + + + )} + {siteConfig('CONTACT_WEIBO') && ( + + + + )} + {siteConfig('CONTACT_INSTAGRAM') && ( + + + + )} + {siteConfig('CONTACT_EMAIL') && ( + + + + )} + {JSON.parse(siteConfig('ENABLE_RSS')) && ( + + + + )} + {siteConfig('CONTACT_BILIBILI') && ( + + + + )} + {siteConfig('CONTACT_YOUTUBE') && ( + + + + )} + {siteConfig('CONTACT_THREADS') && ( + + + + )} +
+
+ ) +} +export default SocialButton diff --git a/themes/typography/components/Title.js b/themes/typography/components/Title.js new file mode 100644 index 00000000..151d736e --- /dev/null +++ b/themes/typography/components/Title.js @@ -0,0 +1,19 @@ +import { siteConfig } from '@/lib/config' + +/** + * 标题栏 + * @param {*} props + * @returns + */ +export const Title = (props) => { + const { post } = props + const title = post?.title || siteConfig('DESCRIPTION') + const description = post?.description || siteConfig('AUTHOR') + + return
+

{title}

+

+ {description} +

+
+} diff --git a/themes/typography/components/TopBar.js b/themes/typography/components/TopBar.js new file mode 100644 index 00000000..e9224de8 --- /dev/null +++ b/themes/typography/components/TopBar.js @@ -0,0 +1,19 @@ +import CONFIG from '../config' +import { siteConfig } from '@/lib/config' + +/** + * 网站顶部 提示栏 + * @returns + */ +export default function TopBar (props) { + const content = siteConfig('SIMPLE_TOP_BAR_CONTENT', null, CONFIG) + + if (content) { + return
+
+
+
+
+ } + return <> +} diff --git a/themes/typography/config.js b/themes/typography/config.js new file mode 100644 index 00000000..bc0b8fc4 --- /dev/null +++ b/themes/typography/config.js @@ -0,0 +1,22 @@ +const CONFIG = { + + SIMPLE_LOGO_IMG: '/Logo.webp', + SIMPLE_TOP_BAR: true, // 显示顶栏 + SIMPLE_TOP_BAR_CONTENT: process.env.NEXT_PUBLIC_THEME_SIMPLE_TOP_TIPS || '', + SIMPLE_LOGO_DESCRIPTION: process.env.NEXT_PUBLIC_THEME_SIMPLE_LOGO_DESCRIPTION || '
编程爱好者
/互联网从业者
/知识分享博主
', + + SIMPLE_AUTHOR_LINK: process.env.NEXT_PUBLIC_AUTHOR_LINK || '#', + + SIMPLE_POST_AD_ENABLE: process.env.NEXT_PUBLIC_SIMPLE_POST_AD_ENABLE || false, // 文章列表是否插入广告 + + SIMPLE_POST_COVER_ENABLE: process.env.NEXT_PUBLIC_SIMPLE_POST_COVER_ENABLE || false, // 是否展示博客封面 + + SIMPLE_ARTICLE_RECOMMEND_POSTS: process.env.NEXT_PUBLIC_SIMPLE_ARTICLE_RECOMMEND_POSTS || true, // 文章详情底部显示推荐 + + // 菜单配置 + SIMPLE_MENU_CATEGORY: true, // 显示分类 + SIMPLE_MENU_TAG: true, // 显示标签 + SIMPLE_MENU_ARCHIVE: true, // 显示归档 + SIMPLE_MENU_SEARCH: true // 显示搜索 +} +export default CONFIG diff --git a/themes/typography/index.js b/themes/typography/index.js new file mode 100644 index 00000000..437596f9 --- /dev/null +++ b/themes/typography/index.js @@ -0,0 +1,357 @@ +import { AdSlot } from '@/components/GoogleAdsense' +import replaceSearchResult from '@/components/Mark' +import NotionPage from '@/components/NotionPage' +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import { isBrowser } from '@/lib/utils' +import { Transition } from '@headlessui/react' +import dynamic from 'next/dynamic' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { createContext, useContext, useEffect, useRef } from 'react' +import BlogPostBar from './components/BlogPostBar' +import CONFIG from './config' +import { Style } from './style' + +const AlgoliaSearchModal = dynamic( + () => import('@/components/AlgoliaSearchModal'), + { ssr: false } +) + +// 主题组件 +const BlogListScroll = dynamic(() => import('./components/BlogListScroll'), { + ssr: false +}) +const BlogArchiveItem = dynamic(() => import('./components/BlogArchiveItem'), { + ssr: false +}) +const ArticleLock = dynamic(() => import('./components/ArticleLock'), { + ssr: false +}) +const ArticleInfo = dynamic(() => import('./components/ArticleInfo'), { + ssr: false +}) +const Comment = dynamic(() => import('@/components/Comment'), { ssr: false }) +const ArticleAround = dynamic(() => import('./components/ArticleAround'), { + ssr: false +}) +const ShareBar = dynamic(() => import('@/components/ShareBar'), { ssr: false }) +const TopBar = dynamic(() => import('./components/TopBar'), { ssr: false }) +const Header = dynamic(() => import('./components/Header'), { ssr: false }) +const NavBar = dynamic(() => import('./components/NavBar'), { ssr: false }) +const JumpToTopButton = dynamic(() => import('./components/JumpToTopButton'), { + ssr: false +}) +const Footer = dynamic(() => import('./components/Footer'), { ssr: false }) +const SearchInput = dynamic(() => import('./components/SearchInput'), { + ssr: false +}) +const WWAds = dynamic(() => import('@/components/WWAds'), { ssr: false }) +const BlogListPage = dynamic(() => import('./components/BlogListPage'), { + ssr: false +}) +const RecommendPosts = dynamic(() => import('./components/RecommendPosts'), { + ssr: false +}) + +// 主题全局状态 +const ThemeGlobalSimple = createContext() +export const useSimpleGlobal = () => useContext(ThemeGlobalSimple) + +/** + * 基础布局 + * + * @param {*} props + * @returns + */ +const LayoutBase = props => { + const { children, slotTop } = props + const { onLoading, fullWidth } = useGlobal() + const searchModal = useRef(null) + + return ( + +
+ + ) +} + +export { Style }