diff --git a/.env.local b/.env.example similarity index 99% rename from .env.local rename to .env.example index f2327cf8..58d0b56f 100644 --- a/.env.local +++ b/.env.example @@ -1,12 +1,11 @@ # 环境变量 @see https://www.nextjs.cn/docs/basic-features/environment-variables -NEXT_PUBLIC_VERSION=4.6.2 - # 可在此添加环境变量,去掉最左边的(# )注释即可 # Notion页面ID,必须 # NOTION_PAGE_ID=097e5f674880459d8e1b4407758dc4fb # 非必须 +# NEXT_PUBLIC_VERSION= # NEXT_PUBLIC_PSEUDO_STATIC= # NEXT_PUBLIC_REVALIDATE_SECOND= # NEXT_PUBLIC_THEME=matery @@ -174,3 +173,4 @@ NEXT_PUBLIC_VERSION=4.6.2 # ENABLE_CACHE= # VERCEL_ENV= # NEXT_PUBLIC_VERSION= +# NEXT_BUILD_STANDALONE= diff --git a/.eslintrc.js b/.eslintrc.js index c6fbb20a..f523d8f0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,15 +4,30 @@ module.exports = { es2021: true, node: true }, - extends: ['plugin:react/recommended', 'plugin:@next/next/recommended', 'standard', 'prettier'], + extends: [ + 'plugin:react/jsx-runtime', + 'plugin:react/recommended', + 'plugin:@next/next/recommended', + 'next', + 'prettier', + 'plugin:@typescript-eslint/recommended', // 添加 TypeScript 推荐规则 + 'plugin:@typescript-eslint/recommended-requiring-type-checking' // 添加需要类型检查的规则 + ], + parser: '@typescript-eslint/parser', // 使用 TypeScript 解析器 parserOptions: { ecmaFeatures: { jsx: true }, ecmaVersion: 12, - sourceType: 'module' + sourceType: 'module', + project: './tsconfig.eslint.json' // 指向新的 ESLint 配置文件 }, - plugins: ['react', 'react-hooks', 'prettier'], + plugins: [ + 'react', + 'react-hooks', + 'prettier', + '@typescript-eslint' // 添加 TypeScript 插件 + ], settings: { react: { version: 'detect' @@ -23,8 +38,31 @@ module.exports = { 'react/no-unknown-property': 'off', // ) } diff --git a/components/LoadingCover.js b/components/LoadingCover.js new file mode 100644 index 00000000..fc51083d --- /dev/null +++ b/components/LoadingCover.js @@ -0,0 +1,77 @@ +'user client' +import { useGlobal } from '@/lib/global' +import { useEffect, useState } from 'react' +/** + * @see https://css-loaders.com/ + * @returns 加载动画 + */ +export default function LoadingCover() { + const { onLoading, setOnLoading } = useGlobal() + const [isVisible, setIsVisible] = useState(false) // 初始状态设置为false,避免服务端渲染与客户端渲染不一致 + + useEffect(() => { + // 确保在客户端渲染时才设置可见性 + if (onLoading) { + setIsVisible(true) + } else { + setIsVisible(false) + } + }, [onLoading]) + + const handleClick = () => { + setOnLoading(false) // 强行关闭 LoadingCover + } + + if (typeof window === 'undefined') { + return null // 避免在服务端渲染时渲染出这个组件 + } + + return isVisible ? ( +
+
+ +
+
+
+ ) : null +} diff --git a/components/LoadingProgress.js b/components/LoadingProgress.js index f217765f..81b98b84 100644 --- a/components/LoadingProgress.js +++ b/components/LoadingProgress.js @@ -3,7 +3,8 @@ import { useRouter } from 'next/router' import { useEffect, useState } from 'react' /** - * 出现页面加载进度条 + * 加载进度条 + * NProgress实现 */ export default function LoadingProgress() { const router = useRouter() diff --git a/components/NotionPage.js b/components/NotionPage.js index 5bc3c31a..eace0637 100644 --- a/components/NotionPage.js +++ b/components/NotionPage.js @@ -1,6 +1,6 @@ import { siteConfig } from '@/lib/config' import { compressImage, mapImgUrl } from '@/lib/notion/mapImage' -import { isBrowser } from '@/lib/utils' +import { isBrowser, loadExternalResource } from '@/lib/utils' import mediumZoom from '@fisch0920/medium-zoom' import 'katex/dist/katex.min.css' import dynamic from 'next/dynamic' @@ -17,6 +17,7 @@ const NotionPage = ({ post, className }) => { // 是否关闭数据库和画册的点击跳转 const POST_DISABLE_GALLERY_CLICK = siteConfig('POST_DISABLE_GALLERY_CLICK') const POST_DISABLE_DATABASE_CLICK = siteConfig('POST_DISABLE_DATABASE_CLICK') + const SPOILER_TEXT_TAG = siteConfig('SPOILER_TEXT_TAG') const zoom = isBrowser && @@ -27,7 +28,7 @@ const NotionPage = ({ post, className }) => { }) const zoomRef = useRef(zoom ? zoom.clone() : null) - + const IMAGE_ZOOM_IN_WIDTH = siteConfig('IMAGE_ZOOM_IN_WIDTH', 1200) // 页面首次打开时执行的勾子 useEffect(() => { // 检测当前的url并自动滚动到对应目标 @@ -64,7 +65,7 @@ const NotionPage = ({ post, className }) => { // 替换为更高清的图像 mutation?.target?.setAttribute( 'src', - compressImage(src, siteConfig('IMAGE_ZOOM_IN_WIDTH', 1200)) + compressImage(src, IMAGE_ZOOM_IN_WIDTH) ) }, 800) } @@ -84,6 +85,21 @@ const NotionPage = ({ post, className }) => { } }, [post]) + useEffect(() => { + // Spoiler文本功能 + if (SPOILER_TEXT_TAG) { + import('lodash/escapeRegExp').then(escapeRegExp => { + Promise.all([ + loadExternalResource('/js/spoilerText.js', 'js'), + loadExternalResource('/css/spoiler-text.css', 'css') + ]).then(() => { + window.textToSpoiler && + window.textToSpoiler(escapeRegExp.default(SPOILER_TEXT_TAG)) + }) + }) + } + }, [post]) + return (
{ const autoScrollToHash = () => { setTimeout(() => { // 跳转到指定标题 - const needToJumpToTitle = window.location.hash + const hash = window?.location?.hash + const needToJumpToTitle = hash && hash.length > 0 if (needToJumpToTitle) { - const tocNode = document.getElementById(window.location.hash.substring(1)) + console.log('jump to hash', hash) + const tocNode = document.getElementById(hash.substring(1)) if (tocNode && tocNode?.className?.indexOf('notion') > -1) { tocNode.scrollIntoView({ block: 'start', behavior: 'smooth' }) } diff --git a/components/OpenWrite.js b/components/OpenWrite.js new file mode 100644 index 00000000..a219830d --- /dev/null +++ b/components/OpenWrite.js @@ -0,0 +1,154 @@ +import { siteConfig } from '@/lib/config' +import { useGlobal } from '@/lib/global' +import { isBrowser, loadExternalResource } from '@/lib/utils' +import { useRouter } from 'next/router' +import { useEffect } from 'react' +/** + * OpenWrite公众号导流插件 + * 使用介绍:https://openwrite.cn/guide/readmore/readmore.html#%E4%BA%8C%E3%80%81%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8 + * 登录后台配置你的博客:https://readmore.openwrite.cn/ + * @returns + */ +const OpenWrite = () => { + const router = useRouter() + const qrcode = siteConfig('OPEN_WRITE_QRCODE', '请配置公众号二维码') + const blogId = siteConfig('OPEN_WRITE_BLOG_ID') + const name = siteConfig('OPEN_WRITE_NAME', '请配置公众号名') + const id = 'article-wrapper' + const keyword = siteConfig('OPEN_WRITE_KEYWORD', '请配置公众号关键词') + const btnText = siteConfig( + 'OPEN_WRITE_BTN_TEXT', + '原创不易,完成人机检测,阅读全文' + ) + // 验证一次后的有效时长,单位小时 + const cookieAge = siteConfig('OPEN_WRITE_VALIDITY_DURATION', 1) + // 白名单 + const whiteList = siteConfig('OPEN_WRITE_WHITE_LIST', '') + + // 登录信息 + const { isLoaded, isSignedIn } = useGlobal() + + const loadOpenWrite = async () => { + try { + await loadExternalResource( + 'https://readmore.openwrite.cn/js/readmore-2.0.js', + 'js' + ) + const BTWPlugin = window?.BTWPlugin + + if (BTWPlugin) { + const btw = new BTWPlugin() + window.btw = btw + btw.init({ + qrcode, + id, + name, + btnText, + keyword, + blogId, + cookieAge + }) + + // btw初始化后,开始监听read-more-wrap何时消失 + const intervalId = setInterval(() => { + const readMoreWrapElement = document.getElementById('read-more-wrap') + const articleWrapElement = document.getElementById('article-wrapper') + + if (!readMoreWrapElement && articleWrapElement) { + toggleTocItems(false) // 恢复目录项的点击 + // 自动调整文章区域的高度 + articleWrapElement.style.height = 'auto' + // 停止定时器 + clearInterval(intervalId) + } + }, 1000) // 每秒检查一次 + + // Return cleanup function to clear the interval if the component unmounts + return () => clearInterval(intervalId) + } + } catch (error) { + console.error('OpenWrite 加载异常', error) + } + } + useEffect(() => { + const existWhite = existedWhiteList(router.asPath, whiteList) + // 白名单中,免检 + if (existWhite) { + return + } + if (isSignedIn) { + // 用户已登录免检 + console.log('用户已登录') + return + } + + // 开发环境免检 + if (process.env.NODE_ENV === 'development') { + console.log('开发环境:屏蔽OpenWrite') + return + } + + if (isBrowser && blogId && !isSignedIn) { + toggleTocItems(true) // 禁止目录项的点击 + + // Check if the element with id 'read-more-wrap' already exists + const readMoreWrap = document.getElementById('read-more-wrap') + + // Only load the script if the element doesn't exist + if (!readMoreWrap) { + loadOpenWrite() + } + } + }, [isLoaded, router]) + + // 启动一个监听器,当页面上存在#read-more-wrap对象时,所有的 a .catalog-item 对象都禁止点击 + + return <> +} + +// 定义禁用和恢复目录项点击的函数 +const toggleTocItems = disable => { + const tocItems = document.querySelectorAll('a.catalog-item') + tocItems.forEach(item => { + if (disable) { + item.style.pointerEvents = 'none' + item.style.opacity = '0.5' + } else { + item.style.pointerEvents = 'auto' + item.style.opacity = '1' + } + }) +} + +/** + * 检查白名单 + * @param {*} path 当前url的字符串 + * @param {*} whiteListStr 白名单字符串 + */ +function existedWhiteList(path, whiteListStr) { + // 参数检查 + if (!path || !whiteListStr) { + return true + } + + // 提取 path 最后一个斜杠后的内容,去掉前面的斜杆 + // 移除查询参数(从 '?' 开始的部分)和 `.html` 后缀 + const processedPath = path + .replace(/\?.*$/, '') // 移除查询参数 + .replace(/.*\/([^/]+)(?:\.html)?$/, '$1') // 去掉前面的路径和 .html + + // 严格检查白名单字符串中是否包含处理后的 path + // const whiteListArray = whiteListStr.split(',') + // return whiteListArray.includes(processedPath) + + // 放宽判断 + const isWhite = whiteListStr.includes(processedPath) + + if (isWhite) { + console.log('OpenWrite白名单', processedPath) + } + + return isWhite +} + +export default OpenWrite diff --git a/components/Player.js b/components/Player.js index 3c741f94..91fef339 100644 --- a/components/Player.js +++ b/components/Player.js @@ -71,10 +71,9 @@ const Player = () => { fixed='true' type='playlist' preload='auto' - lrc-type={siteConfig('MUSIC_PLAYER_METING_LRC_TYPE')} api={siteConfig( 'MUSIC_PLAYER_METING_API', - 'https://api.i-meto.com/meting/api' + 'https://api.i-meto.com/meting/api?server=:server&type=:type&id=:id&r=:r' )} autoplay={autoPlay} order={siteConfig('MUSIC_PLAYER_ORDER')} diff --git a/components/PoweredBy.js b/components/PoweredBy.js new file mode 100644 index 00000000..87b8705f --- /dev/null +++ b/components/PoweredBy.js @@ -0,0 +1,20 @@ +import { siteConfig } from '@/lib/config' + +/** + * 驱动版权 + * @returns + */ +export default function PoweredBy(props) { + return ( +
+ Powered by + + NotionNext {siteConfig('VERSION')} + + . +
+ ) +} diff --git a/components/GlobalHead.js b/components/SEO.js similarity index 91% rename from components/GlobalHead.js rename to components/SEO.js index 0bd3dcca..b102ed11 100644 --- a/components/GlobalHead.js +++ b/components/SEO.js @@ -10,25 +10,55 @@ import { useEffect } from 'react' * @param {*} param0 * @returns */ -const GlobalHead = props => { +const SEO = props => { const { children, siteInfo, post, NOTION_CONFIG } = props - let url = siteConfig('PATH')?.length - ? `${siteConfig('LINK')}/${siteConfig('SUB_PATH', '')}` - : siteConfig('LINK') + const PATH = siteConfig('PATH') + const LINK = siteConfig('LINK') + const SUB_PATH = siteConfig('SUB_PATH', '') + let url = PATH?.length + ? `${LINK}/${SUB_PATH}` + : LINK let image const router = useRouter() const meta = getSEOMeta(props, router, useGlobal()?.locale) + const webFontUrl = siteConfig('FONT_URL') + + useEffect(() => { + // 使用WebFontLoader字体加载 + loadExternalResource( + 'https://cdnjs.cloudflare.com/ajax/libs/webfont/1.6.28/webfontloader.js', + 'js' + ).then(url => { + const WebFont = window?.WebFont + if (WebFont) { + // console.log('LoadWebFont', webFontUrl) + WebFont.load({ + custom: { + // families: ['"LXGW WenKai"'], + urls: webFontUrl + } + }) + } + }) + }, []) + + // SEO关键词 + const KEYWORDS = siteConfig('KEYWORDS') + let keywords = meta?.tags || KEYWORDS + if (post?.tags && post?.tags?.length > 0) { + keywords = post?.tags?.join(',') + } if (meta) { url = `${url}/${meta.slug}` image = meta.image || '/bg_image.jpg' } - const title = meta?.title || siteConfig('TITLE') + const TITLE = siteConfig('TITLE') + const title = meta?.title || TITLE const description = meta?.description || `${siteInfo?.description}` const type = meta?.type || 'website' const lang = siteConfig('LANG').replace('-', '_') // Facebook OpenGraph 要 zh_CN 這樣的格式才抓得到語言 - const category = meta?.category || siteConfig('KEYWORDS') // section 主要是像是 category 這樣的分類,Facebook 用這個來抓連結的分類 + const category = meta?.category || KEYWORDS // section 主要是像是 category 這樣的分類,Facebook 用這個來抓連結的分類 const favicon = siteConfig('BLOG_FAVICON') - const webFontUrl = siteConfig('FONT_URL') const BACKGROUND_DARK = siteConfig('BACKGROUND_DARK', '', NOTION_CONFIG) const SEO_BAIDU_SITE_VERIFICATION = siteConfig( @@ -68,31 +98,8 @@ const GlobalHead = props => { ) const FACEBOOK_PAGE = siteConfig('FACEBOOK_PAGE', null, NOTION_CONFIG) - // SEO关键词 - let keywords = meta?.tags || siteConfig('KEYWORDS') - if (post?.tags && post?.tags?.length > 0) { - keywords = post?.tags?.join(',') - } - - useEffect(() => { - // 使用WebFontLoader字体加载 - loadExternalResource( - 'https://cdnjs.cloudflare.com/ajax/libs/webfont/1.6.28/webfontloader.js', - 'js' - ).then(url => { - const WebFont = window?.WebFont - if (WebFont) { - console.log('LoadWebFont', webFontUrl) - WebFont.load({ - custom: { - // families: ['"LXGW WenKai"'], - urls: webFontUrl - } - }) - } - }) - }, []) + const AUTHOR = siteConfig('AUTHOR') return ( @@ -153,7 +160,7 @@ const GlobalHead = props => { {meta?.type === 'Post' && ( <> - + @@ -172,6 +179,7 @@ const getSEOMeta = (props, router, locale) => { const { post, siteInfo, tag, category, page } = props const keyword = router?.query?.s + const TITLE = siteConfig('TITLE') switch (router.route) { case '/': return { @@ -234,7 +242,7 @@ const getSEOMeta = (props, router, locale) => { case '/search/[keyword]/page/[page]': return { title: `${keyword || ''}${keyword ? ' | ' : ''}${locale.NAV.SEARCH} | ${siteInfo?.title}`, - description: siteConfig('TITLE'), + description: TITLE, image: `${siteInfo?.pageCover}`, slug: 'search/' + (keyword || ''), type: 'website' @@ -275,4 +283,4 @@ const getSEOMeta = (props, router, locale) => { } } -export default GlobalHead +export default SEO diff --git a/components/ShareBar.js b/components/ShareBar.js index 334a5f12..1acbef4e 100644 --- a/components/ShareBar.js +++ b/components/ShareBar.js @@ -1,7 +1,9 @@ import { siteConfig } from '@/lib/config' import dynamic from 'next/dynamic' -const ShareButtons = dynamic(() => import('@/components/ShareButtons'), { ssr: false }) +const ShareButtons = dynamic(() => import('@/components/ShareButtons'), { + ssr: false +}) /** * 分享栏 @@ -9,14 +11,20 @@ const ShareButtons = dynamic(() => import('@/components/ShareButtons'), { ssr: f * @returns */ const ShareBar = ({ post }) => { - if (!JSON.parse(siteConfig('POST_SHARE_BAR_ENABLE')) || !post || post?.type !== 'Post') { + if ( + !JSON.parse(siteConfig('POST_SHARE_BAR_ENABLE')) || + !post || + post?.type !== 'Post' + ) { return <> } - return
-
- -
+ return ( +
+
+ +
+ ) } export default ShareBar diff --git a/components/ShareButtons.js b/components/ShareButtons.js index 853aa3a4..5ea54055 100644 --- a/components/ShareButtons.js +++ b/components/ShareButtons.js @@ -5,48 +5,48 @@ import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { - EmailIcon, - EmailShareButton, - FacebookIcon, - FacebookMessengerIcon, - FacebookMessengerShareButton, - FacebookShareButton, - HatenaIcon, - HatenaShareButton, - InstapaperIcon, - InstapaperShareButton, - LineIcon, - LineShareButton, - LinkedinIcon, - LinkedinShareButton, - LivejournalIcon, - LivejournalShareButton, - MailruIcon, - MailruShareButton, - OKIcon, - OKShareButton, - PinterestIcon, - PinterestShareButton, - PocketIcon, - PocketShareButton, - RedditIcon, - RedditShareButton, - TelegramIcon, - TelegramShareButton, - TumblrIcon, - TumblrShareButton, - TwitterIcon, - TwitterShareButton, - VKIcon, - VKShareButton, - ViberIcon, - ViberShareButton, - WeiboIcon, - WeiboShareButton, - WhatsappIcon, - WhatsappShareButton, - WorkplaceIcon, - WorkplaceShareButton + EmailIcon, + EmailShareButton, + FacebookIcon, + FacebookMessengerIcon, + FacebookMessengerShareButton, + FacebookShareButton, + HatenaIcon, + HatenaShareButton, + InstapaperIcon, + InstapaperShareButton, + LineIcon, + LineShareButton, + LinkedinIcon, + LinkedinShareButton, + LivejournalIcon, + LivejournalShareButton, + MailruIcon, + MailruShareButton, + OKIcon, + OKShareButton, + PinterestIcon, + PinterestShareButton, + PocketIcon, + PocketShareButton, + RedditIcon, + RedditShareButton, + TelegramIcon, + TelegramShareButton, + TumblrIcon, + TumblrShareButton, + TwitterIcon, + TwitterShareButton, + VKIcon, + VKShareButton, + ViberIcon, + ViberShareButton, + WeiboIcon, + WeiboShareButton, + WhatsappIcon, + WhatsappShareButton, + WorkplaceIcon, + WorkplaceShareButton } from 'react-share' const QrCode = dynamic(() => import('@/components/QrCode'), { ssr: false }) @@ -59,8 +59,8 @@ const QrCode = dynamic(() => import('@/components/QrCode'), { ssr: false }) const ShareButtons = ({ post }) => { const router = useRouter() const [shareUrl, setShareUrl] = useState(siteConfig('LINK') + router.asPath) - const title = post.title || siteConfig('TITLE') - const image = post.pageCover + const title = post?.title || siteConfig('TITLE') + const image = post?.pageCover const body = post?.title + ' | ' + title + ' ' + shareUrl + ' ' + post?.summary @@ -70,8 +70,10 @@ const ShareButtons = ({ post }) => { const [qrCodeShow, setQrCodeShow] = useState(false) const copyUrl = () => { - navigator?.clipboard?.writeText(shareUrl) - alert(locale.COMMON.URL_COPIED + ' \n' + shareUrl) + // 确保 shareUrl 是一个正确的字符串并进行解码 + const decodedUrl = decodeURIComponent(shareUrl) + navigator?.clipboard?.writeText(decodedUrl) + alert(locale.COMMON.URL_COPIED + ' \n' + decodedUrl) } const openPopover = () => { diff --git a/components/SideBarDrawer.js b/components/SideBarDrawer.js index 31a29396..c8398a3e 100644 --- a/components/SideBarDrawer.js +++ b/components/SideBarDrawer.js @@ -6,8 +6,16 @@ import { useEffect } from 'react' * @returns {JSX.Element} * @constructor */ -const SideBarDrawer = ({ children, isOpen, onOpen, onClose, className }) => { +const SideBarDrawer = ({ + children, + isOpen, + onOpen, + onClose, + className, + showOnPC = false +}) => { const router = useRouter() + useEffect(() => { const sideBarDrawerRouteListener = () => { switchSideDrawerVisible(false) @@ -19,32 +27,44 @@ const SideBarDrawer = ({ children, isOpen, onOpen, onClose, className }) => { }, [router.events]) // 点击按钮更改侧边抽屉状态 - const switchSideDrawerVisible = (showStatus) => { + 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') + const sideBarDrawerBackground = window.document.getElementById( + 'sidebar-drawer-background' + ) if (showStatus) { - sideBarDrawer?.classList.replace('-ml-60', 'ml-0') + sideBarDrawer?.classList.replace('translate-x-[-100%]', 'translate-x-0') sideBarDrawerBackground?.classList.replace('hidden', 'block') } else { - sideBarDrawer?.classList.replace('ml-0', '-ml-60') + sideBarDrawer?.classList.replace('translate-x-0', 'translate-x-[-100%]') sideBarDrawerBackground?.classList.replace('block', 'hidden') } } - return