diff --git a/themes/magzine/components/Announcement.js b/themes/magzine/components/Announcement.js new file mode 100644 index 00000000..4796f991 --- /dev/null +++ b/themes/magzine/components/Announcement.js @@ -0,0 +1,27 @@ +// import { useGlobal } from '@/lib/global' +import dynamic from 'next/dynamic' + +const NotionPage = dynamic(() => import('@/components/NotionPage')) + +const Announcement = ({ post, className }) => { + // const { locale } = useGlobal() + if (post?.blockMap) { + return ( +
+
+ {/*
{locale.COMMON.ANNOUNCEMENT}
*/} + {post && ( +
+ +
+ )} +
+
+ ) + } else { + return <> + } +} +export default Announcement diff --git a/themes/magzine/components/ArticleAround.js b/themes/magzine/components/ArticleAround.js new file mode 100644 index 00000000..95b6f83f --- /dev/null +++ b/themes/magzine/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.title} + + + + {next.title} + + + +
+ ) +} diff --git a/themes/magzine/components/ArticleInfo.js b/themes/magzine/components/ArticleInfo.js new file mode 100644 index 00000000..b5a71d26 --- /dev/null +++ b/themes/magzine/components/ArticleInfo.js @@ -0,0 +1,57 @@ +import LazyImage from '@/components/LazyImage' +import NotionIcon from '@/components/NotionIcon' +import { siteConfig } from '@/lib/config' +import Link from 'next/link' + +/** + * 文章详情页介绍 + * @param {*} props + * @returns + */ +export default function ArticleInfo(props) { + const { post, siteInfo } = props + + return ( + <> + {/* title */} +

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

+ + {/* meta */} +
+
+ + {' '} + + {post?.publishDay} + + | + + + {post?.lastEditedDay} + +
+ + +
+
+ +
+ + +
+ {siteConfig('AUTHOR')} +
+
+ +
+ + ) +} diff --git a/themes/magzine/components/ArticleLock.js b/themes/magzine/components/ArticleLock.js new file mode 100644 index 00000000..6f2ca8cd --- /dev/null +++ b/themes/magzine/components/ArticleLock.js @@ -0,0 +1,61 @@ +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 dark:text-gray-300 font-light leading-10 text-black bg-gray-100 dark:bg-gray-500'> +
+ +  {locale.COMMON.SUBMIT} + +
+
+
+
+
+ ) +} diff --git a/themes/magzine/components/BlogArchiveItem.js b/themes/magzine/components/BlogArchiveItem.js new file mode 100644 index 00000000..7d7b5a1c --- /dev/null +++ b/themes/magzine/components/BlogArchiveItem.js @@ -0,0 +1,36 @@ +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/magzine/components/BlogPostBar.js b/themes/magzine/components/BlogPostBar.js new file mode 100644 index 00000000..9f34aca5 --- /dev/null +++ b/themes/magzine/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/magzine/components/BlogPostCard.js b/themes/magzine/components/BlogPostCard.js new file mode 100644 index 00000000..ac82bcc1 --- /dev/null +++ b/themes/magzine/components/BlogPostCard.js @@ -0,0 +1,92 @@ +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 Link from 'next/link' +import CONFIG from '../config' +import CategoryItem from './CategoryItem' +import TagItemMini from './TagItemMini' + +const BlogPostCard = ({ post, showSummary }) => { + const showPreview = + siteConfig('MEDIUM_POST_LIST_PREVIEW', null, CONFIG) && post.blockMap + const { locale } = useGlobal() + return ( +
+
+ +

+ {siteConfig('MEDIUM_POST_LIST_COVER', null, CONFIG) && ( +
+ +
+ )} + {siteConfig('POST_TITLE_ICON') && ( + + )} + {post.title} +

+ + +
+
{post.date?.start_date}
+ {siteConfig('MEDIUM_POST_LIST_CATEGORY', null, CONFIG) && ( + + )} + {siteConfig('MEDIUM_POST_LIST_TAG', null, CONFIG) && + post?.tagItems?.map(tag => ( + + ))} + +
+ +
+ + {(!showPreview || showSummary) && ( +
+ {post.summary} +
+ )} + + {showPreview && ( +
+ +
+
+ + {locale.COMMON.ARTICLE_DETAIL} + + +
+
+
+ )} +
+
+ ) +} + +export default BlogPostCard diff --git a/themes/magzine/components/BlogPostCardHorizontal.js b/themes/magzine/components/BlogPostCardHorizontal.js new file mode 100644 index 00000000..076b2d89 --- /dev/null +++ b/themes/magzine/components/BlogPostCardHorizontal.js @@ -0,0 +1,85 @@ +import LazyImage from '@/components/LazyImage' +import NotionIcon from '@/components/NotionIcon' +import NotionPage from '@/components/NotionPage' +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import Link from 'next/link' +import CONFIG from '../config' +import CategoryItem from './CategoryItem' +import TagItemMini from './TagItemMini' + +const BlogPostCardHorizontal = ({ post, showSummary }) => { + const showPreview = + siteConfig('MEDIUM_POST_LIST_PREVIEW', null, CONFIG) && post.blockMap + const { locale } = useGlobal() + return ( +
+ {/* 卡牌左侧 */} +
+ +

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

+ + +
+
{post.date?.start_date}
+ {siteConfig('MEDIUM_POST_LIST_CATEGORY', null, CONFIG) && ( + + )} + {siteConfig('MEDIUM_POST_LIST_TAG', null, CONFIG) && + post?.tagItems?.map(tag => ( + + ))} +
+ + {(!showPreview || showSummary) && ( +
+ {post.summary} +
+ )} + + {showPreview && ( +
+ +
+
+ + {locale.COMMON.ARTICLE_DETAIL} + + +
+
+
+ )} +
+ + {/* 卡牌右侧图片 */} +
+ +
+
+ ) +} + +export default BlogPostCardHorizontal diff --git a/themes/magzine/components/BlogPostCardTop.js b/themes/magzine/components/BlogPostCardTop.js new file mode 100644 index 00000000..2c02d02e --- /dev/null +++ b/themes/magzine/components/BlogPostCardTop.js @@ -0,0 +1,106 @@ +import LazyImage from '@/components/LazyImage' +import NotionIcon from '@/components/NotionIcon' +import NotionPage from '@/components/NotionPage' +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import Link from 'next/link' +import CONFIG from '../config' +import CategoryItem from './CategoryItem' +import TagItemMini from './TagItemMini' + +/** + * 置顶头条文章 + * @param {*} param0 + * @returns + */ +const BlogPostCardTop = ({ post, showSummary }) => { + const showPreview = + siteConfig('MEDIUM_POST_LIST_PREVIEW', null, CONFIG) && post.blockMap + const { locale } = useGlobal() + return ( +
+
+ {siteConfig('MEDIUM_POST_LIST_COVER', null, CONFIG) && ( + +
+ +
+ + )} + +
+ {siteConfig('MEDIUM_POST_LIST_CATEGORY', null, CONFIG) && ( + + )} +
+ {siteConfig('MEDIUM_POST_LIST_TAG', null, CONFIG) && + post?.tagItems?.map(tag => ( + + ))} +
+
+ + +

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

+ + +
+ + {(!showPreview || showSummary) && ( +
+ {post.summary} +
+ )} + + {showPreview && ( +
+ +
+
+ + {locale.COMMON.ARTICLE_DETAIL} + + +
+
+
+ )} + +
{post.date?.start_date}
+
+
+ ) +} + +export default BlogPostCardTop diff --git a/themes/magzine/components/BlogPostListEmpty.js b/themes/magzine/components/BlogPostListEmpty.js new file mode 100644 index 00000000..a26cf292 --- /dev/null +++ b/themes/magzine/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_RESULTS_FOUND} {(currentSearch &&

{currentSearch}
)}

+
+} +export default BlogPostListEmpty diff --git a/themes/magzine/components/BlogPostListPage.js b/themes/magzine/components/BlogPostListPage.js new file mode 100644 index 00000000..79e031dd --- /dev/null +++ b/themes/magzine/components/BlogPostListPage.js @@ -0,0 +1,37 @@ +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import BlogPostCard from './BlogPostCard' +import BlogPostListEmpty from './BlogPostListEmpty' +import PaginationSimple from './PaginationSimple' + +/** + * 文章列表分页表格 + * @param page 当前页 + * @param posts 所有文章 + * @param tags 所有标签 + * @returns {JSX.Element} + * @constructor + */ +const BlogPostListPage = ({ page = 1, posts = [], postCount }) => { + const { NOTION_CONFIG } = useGlobal() + const POSTS_PER_PAGE = siteConfig('POSTS_PER_PAGE', null, NOTION_CONFIG) + const totalPage = Math.ceil(postCount / POSTS_PER_PAGE) + + if (!posts || posts.length === 0) { + return + } + + return ( +
+
+ {/* 文章列表 */} + {posts?.map(post => ( + + ))} +
+ +
+ ) +} + +export default BlogPostListPage diff --git a/themes/magzine/components/BlogPostListScroll.js b/themes/magzine/components/BlogPostListScroll.js new file mode 100644 index 00000000..22960779 --- /dev/null +++ b/themes/magzine/components/BlogPostListScroll.js @@ -0,0 +1,107 @@ +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import throttle from 'lodash.throttle' +import { useRouter } from 'next/router' +import { useCallback, useEffect, useRef, useState } from 'react' +import BlogPostCard from './BlogPostCard' +import BlogPostListEmpty from './BlogPostListEmpty' + +/** + * 博客列表滚动分页 + * @param posts 所有文章 + * @param tags 所有标签 + * @returns {JSX.Element} + * @constructor + */ +const BlogPostListScroll = ({ posts = [], currentSearch }) => { + const { NOTION_CONFIG } = useGlobal() + const POSTS_PER_PAGE = siteConfig('POSTS_PER_PAGE', null, NOTION_CONFIG) + const [page, updatePage] = useState(1) + const router = useRouter() + let filteredPosts = Object.assign(posts) + const searchKey = router?.query?.s || null + if (searchKey) { + filteredPosts = posts.filter(post => { + const tagContent = post?.tags ? post?.tags.join(' ') : '' + const searchContent = post.title + post.summary + tagContent + return searchContent.toLowerCase().includes(searchKey.toLowerCase()) + }) + } + const postsToShow = getPostByPage(page, filteredPosts, POSTS_PER_PAGE) + + let hasMore = false + if (filteredPosts) { + const totalCount = filteredPosts.length + hasMore = page * POSTS_PER_PAGE < totalCount + } + + const handleGetMore = () => { + if (!hasMore) return + updatePage(page + 1) + } + + // 监听滚动自动分页加载 + 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) + } + }) + + const targetRef = 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 dark:text-gray-200'> + {' '} + {hasMore ? locale.COMMON.MORE : `${locale.COMMON.NO_MORE} 😰`}{' '} +
+
+
+ ) + } +} + +/** + * 获取从第1页到指定页码的文章 + * @param page 第几页 + * @param totalPosts 所有文章 + * @param POSTS_PER_PAGE 每页文章数量 + * @returns {*} + */ +const getPostByPage = function (page, totalPosts, POSTS_PER_PAGE) { + return totalPosts.slice(0, POSTS_PER_PAGE * page) +} + +export default BlogPostListScroll diff --git a/themes/magzine/components/Card.js b/themes/magzine/components/Card.js new file mode 100644 index 00000000..d24c046e --- /dev/null +++ b/themes/magzine/components/Card.js @@ -0,0 +1,9 @@ +const Card = ({ children, headerSlot, className }) => { + return
+ <>{headerSlot} +
+ {children} +
+
+} +export default Card diff --git a/themes/magzine/components/Catalog.js b/themes/magzine/components/Catalog.js new file mode 100644 index 00000000..b390e686 --- /dev/null +++ b/themes/magzine/components/Catalog.js @@ -0,0 +1,99 @@ +import throttle from 'lodash.throttle' +import { uuidToId } from 'notion-utils' +import { useCallback, useEffect, useRef, useState } from 'react' +import Progress from './Progress' + +/** + * 目录导航组件 + * @param toc + * @returns {JSX.Element} + * @constructor + */ +const Catalog = ({ toc }) => { + const tocIds = [] + + // 目录自动滚动 + const tRef = useRef(null) + // 同步选中目录事件 + const [activeSection, setActiveSection] = useState(null) + + // 监听滚动事件 + useEffect(() => { + window.addEventListener('scroll', actionSectionScrollSpy) + actionSectionScrollSpy() + return () => { + window.removeEventListener('scroll', actionSectionScrollSpy) + } + }, []) + + const throttleMs = 200 + const actionSectionScrollSpy = 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 ( +
+
+ +
+
+ +
+
+ ) +} + +export default Catalog diff --git a/themes/magzine/components/CategoryGroup.js b/themes/magzine/components/CategoryGroup.js new file mode 100644 index 00000000..3fd6b67d --- /dev/null +++ b/themes/magzine/components/CategoryGroup.js @@ -0,0 +1,37 @@ +import { useGlobal } from '@/lib/global' +import CategoryItem from './CategoryItem' + +/** + * 分类 + * @param {*} param0 + * @returns + */ +const CategoryGroup = ({ currentCategory, categoryOptions }) => { + const { locale } = useGlobal() + if (!categoryOptions) { + return <> + } + return ( +
+
+ + {locale.COMMON.CATEGORY} +
+
+ {categoryOptions?.map(category => { + const selected = currentCategory === category.name + return ( + + ) + })} +
+
+ ) +} + +export default CategoryGroup diff --git a/themes/magzine/components/CategoryItem.js b/themes/magzine/components/CategoryItem.js new file mode 100644 index 00000000..399049ef --- /dev/null +++ b/themes/magzine/components/CategoryItem.js @@ -0,0 +1,22 @@ +import Link from 'next/link' + +export default function CategoryItem({ selected, category, categoryCount }) { + return ( + +
+ + {category} {categoryCount && `(${categoryCount})`} +
+ + ) +} diff --git a/themes/magzine/components/Footer.js b/themes/magzine/components/Footer.js new file mode 100644 index 00000000..0c5a9211 --- /dev/null +++ b/themes/magzine/components/Footer.js @@ -0,0 +1,30 @@ +import DarkModeButton from '@/components/DarkModeButton' +import { siteConfig } from '@/lib/config' + +const Footer = ({ title }) => { + const d = new Date() + const currentYear = d.getFullYear() + const since = siteConfig('SINCE') + const copyrightDate = parseInt(since) < currentYear ? since + '-' + currentYear : currentYear + + return ( + + ) +} + +export default Footer diff --git a/themes/magzine/components/Header.js b/themes/magzine/components/Header.js new file mode 100644 index 00000000..b4a8fd22 --- /dev/null +++ b/themes/magzine/components/Header.js @@ -0,0 +1,136 @@ +import Collapse from '@/components/Collapse' +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import throttle from 'lodash.throttle' +import { useEffect, useRef, useState } from 'react' +import CONFIG from '../config' +import LogoBar from './LogoBar' +import { MenuBarMobile } from './MenuBarMobile' +import { MenuItemDrop } from './MenuItemDrop' + +/** + * 顶部导航栏 + 菜单 + * @param {} param0 + * @returns + */ +export default function Header(props) { + const { customNav, customMenu } = props + const [isOpen, changeShow] = useState(false) + const collapseRef = useRef(null) + + const { locale } = useGlobal() + + const defaultLinks = [ + { + icon: 'fas fa-th', + name: locale.COMMON.CATEGORY, + href: '/category', + show: CONFIG.MENU_CATEGORY + }, + { + icon: 'fas fa-tag', + name: locale.COMMON.TAGS, + href: '/tag', + show: CONFIG.MENU_TAG + }, + { + icon: 'fas fa-archive', + name: locale.NAV.ARCHIVE, + href: '/archive', + show: CONFIG.MENU_ARCHIVE + }, + { + icon: 'fas fa-search', + name: locale.NAV.SEARCH, + href: '/search', + show: CONFIG.MENU_SEARCH + } + ] + + let links = defaultLinks.concat(customNav) + + const toggleMenuOpen = () => { + changeShow(!isOpen) + } + + // 向下滚动时,调整导航条高度 + useEffect(() => { + scrollTrigger() + window.addEventListener('scroll', scrollTrigger) + return () => { + window.removeEventListener('scroll', scrollTrigger) + } + }, []) + + const throttleMs = 150 + + const scrollTrigger = throttle(() => { + const scrollS = window.scrollY + const nav = document.querySelector('#top-navbar') + + const narrowNav = scrollS > 50 + if (narrowNav) { + nav && nav.classList.replace('h-20', 'h-14') + } else { + nav && nav.classList.replace('h-14', 'h-20') + } + }, throttleMs) + + // 如果 开启自定义菜单,则覆盖Page生成的菜单 + if (siteConfig('CUSTOM_MENU')) { + links = customMenu + } + + if (!links || links.length === 0) { + return null + } + + return ( +
+ {/* 导航栏菜单内容 */} +
+ {/* 左侧图标Logo */} + + + {/* 移动端折叠按钮 */} +
+
+ {isOpen ? ( + + ) : ( + + )} +
+
+ + {/* 桌面端顶部菜单 */} +
+ {links && + links?.map(link => )} +
+
+ + {/* 移动端折叠菜单 */} + +
+ + collapseRef.current?.updateCollapseHeight(param) + } + /> +
+
+
+ ) +} diff --git a/themes/magzine/components/InfoCard.js b/themes/magzine/components/InfoCard.js new file mode 100644 index 00000000..abdc8862 --- /dev/null +++ b/themes/magzine/components/InfoCard.js @@ -0,0 +1,39 @@ +import LazyImage from '@/components/LazyImage' +import { siteConfig } from '@/lib/config' +import Router from 'next/router' +import SocialButton from './SocialButton' + +/** + * 用户信息卡 + * @param {*} props + * @returns + */ +const InfoCard = props => { + const { siteInfo } = props + return ( +
+
+
{ + Router.push('/about') + }}> + +
+
+ {siteConfig('AUTHOR')} +
+
+ {siteConfig('BIO')} +
+ +
+
+ ) +} + +export default InfoCard diff --git a/themes/magzine/components/JumpToTopButton.js b/themes/magzine/components/JumpToTopButton.js new file mode 100644 index 00000000..6342bf78 --- /dev/null +++ b/themes/magzine/components/JumpToTopButton.js @@ -0,0 +1,29 @@ +import CONFIG from '../config' +import { siteConfig } from '@/lib/config' + +/** + * 跳转到网页顶部 + * 当屏幕下滑500像素后会出现该控件 + * @param targetRef 关联高度的目标html标签 + * @param showPercent 是否显示百分比 + * @returns {JSX.Element} + * @constructor + */ +const JumpToTopButton = ({ showPercent = false, percent, className }) => { + if (!siteConfig('MEDIUM_WIDGET_TO_TOP', null, CONFIG)) { + return <> + } + return ( +
+ { window.scrollTo({ top: 0, behavior: 'smooth' }) }} /> +
+ ) +} + +export default JumpToTopButton diff --git a/themes/magzine/components/LeftMenuBar.js b/themes/magzine/components/LeftMenuBar.js new file mode 100644 index 00000000..6bde6c51 --- /dev/null +++ b/themes/magzine/components/LeftMenuBar.js @@ -0,0 +1,15 @@ +import Link from 'next/link' + +export default function LeftMenuBar () { + return ( +
+
+ +
+ +
+ +
+
+ ); +} diff --git a/themes/magzine/components/LoadingCover.js b/themes/magzine/components/LoadingCover.js new file mode 100644 index 00000000..4d8fa828 --- /dev/null +++ b/themes/magzine/components/LoadingCover.js @@ -0,0 +1,7 @@ +export default function LoadingCover() { + return
+
+ +
+
+} diff --git a/themes/magzine/components/LogoBar.js b/themes/magzine/components/LogoBar.js new file mode 100644 index 00000000..dfe01f9a --- /dev/null +++ b/themes/magzine/components/LogoBar.js @@ -0,0 +1,20 @@ +import { siteConfig } from '@/lib/config' +import Link from 'next/link' + +export default function LogoBar(props) { + const { siteInfo } = props + return ( +
+ + {/* */} + {siteConfig('TITLE')} + +
+ ) +} diff --git a/themes/magzine/components/MenuBarMobile.js b/themes/magzine/components/MenuBarMobile.js new file mode 100644 index 00000000..8e505130 --- /dev/null +++ b/themes/magzine/components/MenuBarMobile.js @@ -0,0 +1,54 @@ +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import CONFIG from '../config' +import { MenuItemCollapse } from './MenuItemCollapse' + +export const MenuBarMobile = props => { + const { customMenu, customNav } = props + const { locale } = useGlobal() + + let links = [ + // { name: locale.NAV.INDEX, href: '/' || '/', show: true }, + { + name: locale.COMMON.CATEGORY, + href: '/category', + show: siteConfig('MEDIUM_MENU_CATEGORY', null, CONFIG) + }, + { + name: locale.COMMON.TAGS, + href: '/tag', + show: siteConfig('MEDIUM_MENU_TAG', null, CONFIG) + }, + { + name: locale.NAV.ARCHIVE, + href: '/archive', + show: siteConfig('MEDIUM_MENU_ARCHIVE', null, CONFIG) + } + // { name: locale.NAV.SEARCH, href: '/search', show: siteConfig('MENU_SEARCH', null, CONFIG) } + ] + + if (customNav) { + links = links.concat(customNav) + } + + // 如果 开启自定义菜单,则不再使用 Page生成菜单。 + if (siteConfig('CUSTOM_MENU')) { + links = customMenu + } + + if (!links || links.length === 0) { + return null + } + + return ( + + ) +} diff --git a/themes/magzine/components/MenuItemCollapse.js b/themes/magzine/components/MenuItemCollapse.js new file mode 100644 index 00000000..fcd8b756 --- /dev/null +++ b/themes/magzine/components/MenuItemCollapse.js @@ -0,0 +1,97 @@ +import Collapse from '@/components/Collapse' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useState } from 'react' + +/** + * 折叠菜单 + * @param {*} param0 + * @returns + */ +export const MenuItemCollapse = props => { + const { link } = props + const [show, changeShow] = useState(false) + const hasSubMenu = link?.subMenus?.length > 0 + + const [isOpen, changeIsOpen] = useState(false) + + const router = useRouter() + + if (!link || !link.show) { + return null + } + + const selected = router.pathname === link.href || router.asPath === link.href + + const toggleShow = () => { + changeShow(!show) + } + + const toggleOpenSubMenu = () => { + changeIsOpen(!isOpen) + } + + return ( + <> +
+ {!hasSubMenu && ( + +
+
+ {link.name} +
+ + )} + + {hasSubMenu && ( +
+
+
+ {link.name} +
+
+ +
+
+ )} +
+ + {/* 折叠子菜单 */} + {hasSubMenu && ( + + {link?.subMenus?.map(sLink => { + return ( +
+ +
+
+ {sLink.title} +
+ +
+ ) + })} + + )} + + ) +} diff --git a/themes/magzine/components/MenuItemDrop.js b/themes/magzine/components/MenuItemDrop.js new file mode 100644 index 00000000..38484029 --- /dev/null +++ b/themes/magzine/components/MenuItemDrop.js @@ -0,0 +1,76 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useState } from 'react' + +export const MenuItemDrop = ({ link }) => { + const [show, changeShow] = useState(false) + // const show = true + // const changeShow = () => {} + const router = useRouter() + + if (!link || !link.show) { + return null + } + const hasSubMenu = link?.subMenus?.length > 0 + const selected = router.pathname === link.href || router.asPath === link.href + + return ( +
  • changeShow(true)} + onMouseOut={() => changeShow(false)}> + {hasSubMenu && ( +
    +
    + {link?.icon && } {link?.name} + {hasSubMenu && ( + + )} +
    +
    + )} + + {!hasSubMenu && ( +
    + + {link?.icon && } {link?.name} + +
    + )} + + {/* 子菜单 */} + {hasSubMenu && ( +
      + {link?.subMenus?.map(sLink => { + return ( +
    • + + + {link?.icon &&   } + {sLink.title} + + +
    • + ) + })} +
    + )} +
  • + ) +} diff --git a/themes/magzine/components/MenuItemMobileNormal.js b/themes/magzine/components/MenuItemMobileNormal.js new file mode 100644 index 00000000..33569bfb --- /dev/null +++ b/themes/magzine/components/MenuItemMobileNormal.js @@ -0,0 +1,29 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export const NormalMenu = props => { + const { link } = props + const router = useRouter() + + if (!link || !link.show) { + return null + } + + const selected = router.pathname === link.href || router.asPath === link.href + + return ( + +
    +
    {link.name}
    +
    + {link.slot} + + ) +} diff --git a/themes/magzine/components/MenuItemPCNormal.js b/themes/magzine/components/MenuItemPCNormal.js new file mode 100644 index 00000000..e93ca07f --- /dev/null +++ b/themes/magzine/components/MenuItemPCNormal.js @@ -0,0 +1,30 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export const MenuItemPCNormal = props => { + const { link } = props + const router = useRouter() + const selected = router.pathname === link.href || router.asPath === link.href + if (!link || !link.show) { + return null + } + + return ( + +
    + +
    {link.name}
    +
    + {link.slot} + + ) +} diff --git a/themes/magzine/components/PaginationSimple.js b/themes/magzine/components/PaginationSimple.js new file mode 100644 index 00000000..0fcbebab --- /dev/null +++ b/themes/magzine/components/PaginationSimple.js @@ -0,0 +1,55 @@ +import { useGlobal } from '@/lib/global' +import Link from 'next/link' +import { useRouter } from 'next/router' + +/** + * 简易翻页插件 + * @param page 当前页码 + * @param totalPage 是否有下一页 + * @returns {JSX.Element} + * @constructor + */ +const PaginationSimple = ({ page, totalPage }) => { + const { locale } = useGlobal() + const router = useRouter() + const currentPage = +page + const showNext = currentPage < totalPage + const pagePrefix = router.asPath + .split('?')[0] + .replace(/\/page\/[1-9]\d*/, '') + .replace(/\/$/, '') + + return ( +
    + + ←{locale.PAGINATION.PREV} + + + {locale.PAGINATION.NEXT}→ + +
    + ) +} + +export default PaginationSimple diff --git a/themes/magzine/components/Progress.js b/themes/magzine/components/Progress.js new file mode 100644 index 00000000..f6fa94a6 --- /dev/null +++ b/themes/magzine/components/Progress.js @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react' +import { isBrowser } from '@/lib/utils' + +/** + * 顶部页面阅读进度条 + * @returns {JSX.Element} + * @constructor + */ +const Progress = ({ targetRef, showPercent = true }) => { + const currentRef = targetRef?.current || targetRef + const [percent, changePercent] = useState(0) + const scrollListener = () => { + const target = currentRef || (isBrowser && document.getElementById('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/magzine/components/RevolverMaps.js b/themes/magzine/components/RevolverMaps.js new file mode 100644 index 00000000..a65fc4cf --- /dev/null +++ b/themes/magzine/components/RevolverMaps.js @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react' + +export default function RevolverMaps () { + const [load, changeLoad] = useState(false) + useEffect(() => { + if (!load) { + initRevolverMaps() + changeLoad(true) + } + }) + return
    +} + +function initRevolverMaps () { + if (screen.width >= 768) { + Promise.all([ + loadExternalResource('https://rf.revolvermaps.com/0/0/8.js?i=5jnp1havmh9&m=0&c=ff0000&cr1=ffffff&f=arial&l=33') + ]).then(() => { + // console.log('地图加载完成') + }) + } +} + +// 封装异步加载资源的方法 +function loadExternalResource (url) { + return new Promise((resolve, reject) => { + const container = document.getElementById('revolvermaps') + const tag = document.createElement('script') + tag.src = url + if (tag) { + tag.onload = () => resolve(url) + tag.onerror = () => reject(url) + container.appendChild(tag) + } + }) +} diff --git a/themes/magzine/components/SearchInput.js b/themes/magzine/components/SearchInput.js new file mode 100644 index 00000000..f6c84d9c --- /dev/null +++ b/themes/magzine/components/SearchInput.js @@ -0,0 +1,86 @@ +import { useRouter } from 'next/router' +import { useImperativeHandle, useRef, useState } from 'react' +let lock = false + +const SearchInput = ({ currentTag, currentSearch, 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={currentSearch} + /> + +
    + +
    + + {(showClean && +
    + +
    + )} +
    +} + +export default SearchInput diff --git a/themes/magzine/components/SocialButton.js b/themes/magzine/components/SocialButton.js new file mode 100644 index 00000000..97aa6824 --- /dev/null +++ b/themes/magzine/components/SocialButton.js @@ -0,0 +1,104 @@ +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') && ( + + + + )} +
    + ) +} +export default SocialButton diff --git a/themes/magzine/components/TagGroups.js b/themes/magzine/components/TagGroups.js new file mode 100644 index 00000000..dc0895f8 --- /dev/null +++ b/themes/magzine/components/TagGroups.js @@ -0,0 +1,30 @@ +import { useGlobal } from '@/lib/global' +import TagItemMini from './TagItemMini' + +/** + * 标签组 + * @param tags + * @param currentTag + * @returns {JSX.Element} + * @constructor + */ +const TagGroups = ({ tagOptions, currentTag }) => { + const { locale } = useGlobal() + if (!tagOptions) return <> + return ( +
    +
    + + {locale.COMMON.TAGS} +
    +
    + {tagOptions?.map(tag => { + const selected = tag.name === currentTag + return + })} +
    +
    + ) +} + +export default TagGroups diff --git a/themes/magzine/components/TagItemMini.js b/themes/magzine/components/TagItemMini.js new file mode 100644 index 00000000..9922a069 --- /dev/null +++ b/themes/magzine/components/TagItemMini.js @@ -0,0 +1,21 @@ +import Link from 'next/link' + +const TagItemMini = ({ tag, selected = false }) => { + return ( + + +
    {selected && } {tag.name + (tag.count ? `(${tag.count})` : '')}
    + + + ) +} + +export default TagItemMini diff --git a/themes/magzine/components/TocDrawer.js b/themes/magzine/components/TocDrawer.js new file mode 100644 index 00000000..e8367486 --- /dev/null +++ b/themes/magzine/components/TocDrawer.js @@ -0,0 +1,48 @@ +import { useMagzineGlobal } from '..' +import Catalog from './Catalog' + +/** + * 悬浮抽屉目录 + * @param toc + * @param post + * @returns {JSX.Element} + * @constructor + */ +const TocDrawer = ({ post, cRef }) => { + const { tocVisible, changeTocVisible } = useMagzineGlobal() + const switchVisible = () => { + changeTocVisible(!tocVisible) + } + return ( + <> +
    + {/* 侧边菜单 */} +
    + {post && ( + <> +
    + +
    + + )} +
    +
    + {/* 背景蒙版 */} +
    + + ) +} +export default TocDrawer diff --git a/themes/magzine/components/TopNavBar.js b/themes/magzine/components/TopNavBar.js new file mode 100644 index 00000000..244a02d7 --- /dev/null +++ b/themes/magzine/components/TopNavBar.js @@ -0,0 +1,110 @@ +import Collapse from '@/components/Collapse' +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import { useRef, useState } from 'react' +import CONFIG from '../config' +import LogoBar from './LogoBar' +import { MenuBarMobile } from './MenuBarMobile' +import { MenuItemDrop } from './MenuItemDrop' + +/** + * 顶部导航栏 + 菜单 + * @param {} param0 + * @returns + */ +export default function TopNavBar(props) { + const { className, customNav, customMenu } = props + const [isOpen, changeShow] = useState(false) + const collapseRef = useRef(null) + + const { locale } = useGlobal() + + const defaultLinks = [ + { + icon: 'fas fa-th', + name: locale.COMMON.CATEGORY, + href: '/category', + show: siteConfig('MEDIUM_MENU_CATEGORY', null, CONFIG) + }, + { + icon: 'fas fa-tag', + name: locale.COMMON.TAGS, + href: '/tag', + show: siteConfig('MEDIUM_MENU_TAG', null, CONFIG) + }, + { + icon: 'fas fa-archive', + name: locale.NAV.ARCHIVE, + href: '/archive', + show: siteConfig('MEDIUM_MENU_ARCHIVE', null, CONFIG) + }, + { + icon: 'fas fa-search', + name: locale.NAV.SEARCH, + href: '/search', + show: siteConfig('MEDIUM_MENU_SEARCH', null, CONFIG) + } + ] + + let links = defaultLinks.concat(customNav) + + const toggleMenuOpen = () => { + changeShow(!isOpen) + } + + // 如果 开启自定义菜单,则覆盖Page生成的菜单 + if (siteConfig('CUSTOM_MENU')) { + links = customMenu + } + + if (!links || links.length === 0) { + return null + } + + return ( +
    + {/* 移动端折叠菜单 */} + +
    + + collapseRef.current?.updateCollapseHeight(param) + } + /> +
    +
    + + {/* 导航栏菜单 */} +
    + {/* 左侧图标Logo */} + + + {/* 折叠按钮、仅移动端显示 */} +
    +
    + {isOpen ? ( + + ) : ( + + )} +
    +
    + + {/* 桌面端顶部菜单 */} +
    + {links && + links?.map((link, index) => ( + + ))} +
    +
    +
    + ) +} diff --git a/themes/magzine/config.js b/themes/magzine/config.js new file mode 100644 index 00000000..c29a2ef2 --- /dev/null +++ b/themes/magzine/config.js @@ -0,0 +1,24 @@ +const CONFIG = { + + // Style + MEDIUM_RIGHT_PANEL_DARK: process.env.NEXT_PUBLIC_MEDIUM_RIGHT_DARK || false, // 右侧面板深色模式 + + MEDIUM_POST_LIST_COVER: true, // 文章列表显示图片封面 + MEDIUM_POST_LIST_PREVIEW: true, // 列表显示文章预览 + MEDIUM_POST_LIST_CATEGORY: true, // 列表显示文章分类 + MEDIUM_POST_LIST_TAG: true, // 列表显示文章标签 + + MEDIUM_POST_DETAIL_CATEGORY: true, // 文章显示分类 + MEDIUM_POST_DETAIL_TAG: true, // 文章显示标签 + + // 菜单 + MEDIUM_MENU_CATEGORY: true, // 显示分类 + MEDIUM_MENU_TAG: true, // 显示标签 + MEDIUM_MENU_ARCHIVE: true, // 显示归档 + MEDIUM_MENU_SEARCH: true, // 显示搜索 + + // Widget + MEDIUM_WIDGET_REVOLVER_MAPS: process.env.NEXT_PUBLIC_WIDGET_REVOLVER_MAPS || 'false', // 地图插件 + MEDIUM_WIDGET_TO_TOP: true // 跳回顶部 +} +export default CONFIG diff --git a/themes/magzine/index.js b/themes/magzine/index.js new file mode 100644 index 00000000..db46d13e --- /dev/null +++ b/themes/magzine/index.js @@ -0,0 +1,374 @@ +import Comment from '@/components/Comment' +import replaceSearchResult from '@/components/Mark' +import NotionPage from '@/components/NotionPage' +import ShareBar from '@/components/ShareBar' +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import { isBrowser } from '@/lib/utils' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { createContext, useContext, useEffect, useState } from 'react' +import Announcement from './components/Announcement' +import ArticleAround from './components/ArticleAround' +import ArticleInfo from './components/ArticleInfo' +import { ArticleLock } from './components/ArticleLock' +import BlogArchiveItem from './components/BlogArchiveItem' +import BlogPostCardHorizontal from './components/BlogPostCardHorizontal' +import BlogPostCardTop from './components/BlogPostCardTop' +import BlogPostListPage from './components/BlogPostListPage' +import BlogPostListScroll from './components/BlogPostListScroll' +import Catalog from './components/Catalog' +import CategoryGroup from './components/CategoryGroup' +import CategoryItem from './components/CategoryItem' +import Footer from './components/Footer' +import Header from './components/Header' +import InfoCard from './components/InfoCard' +import SearchInput from './components/SearchInput' +import TagGroups from './components/TagGroups' +import TagItemMini from './components/TagItemMini' +import TocDrawer from './components/TocDrawer' +import CONFIG from './config' +import { Style } from './style' + +// 主题全局状态 +const ThemeGlobalMagzine = createContext() +export const useMagzineGlobal = () => useContext(ThemeGlobalMagzine) + +/** + * 基础布局 + * 采用左右两侧布局,移动端使用顶部导航栏 + * @returns {JSX.Element} + * @constructor + */ +const LayoutBase = props => { + const { children, showInfoCard = true, post, notice } = props + const { locale } = useGlobal() + const router = useRouter() + const [tocVisible, changeTocVisible] = useState(false) + const { onLoading, fullWidth } = useGlobal() + const [slotRight, setSlotRight] = useState(null) + + return ( + + {/* CSS样式 */} + +} + +export { Style }