@@ -1,2 +1,2 @@
|
||||
# 环境变量 @see https://www.nextjs.cn/docs/basic-features/environment-variables
|
||||
NEXT_PUBLIC_VERSION=3.16.4
|
||||
NEXT_PUBLIC_VERSION=4.0.0
|
||||
@@ -26,6 +26,7 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'react/no-unknown-property': 'off', // <style jsx>
|
||||
'react/prop-types': 'off',
|
||||
'space-before-function-paren': 0,
|
||||
'react-hooks/rules-of-hooks': 'error' // Checks rules of Hooks
|
||||
|
||||
@@ -35,7 +35,7 @@ const BLOG = {
|
||||
NOTION_HOST: process.env.NEXT_PUBLIC_NOTION_HOST || 'https://www.notion.so', // Notion域名,您可以选择用自己的域名进行反向代理,如果不懂得什么是反向代理,请勿修改此项
|
||||
|
||||
// 网站字体
|
||||
FONT_STYLE: process.env.NEXT_PUBLIC_FONT_STYLE || 'font-serif', // ['font-serif','font-sans'] 两种可选,分别是衬线和无衬线: 参考 https://www.jianshu.com/p/55e410bd2115
|
||||
FONT_STYLE: process.env.NEXT_PUBLIC_FONT_STYLE || 'font-sans', // ['font-serif','font-sans'] 两种可选,分别是衬线和无衬线: 参考 https://www.jianshu.com/p/55e410bd2115
|
||||
FONT_URL: [
|
||||
// 字体CSS 例如 https://npm.elemecdn.com/lxgw-wenkai-webfont@1.6.0/style.css
|
||||
'https://fonts.googleapis.com/css?family=Bitter&display=swap',
|
||||
@@ -48,12 +48,12 @@ const BLOG = {
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Hiragino Sans GB"',
|
||||
'"Microsoft YaHei"',
|
||||
'"Segoe UI Emoji"',
|
||||
'"Segoe UI Symbol"',
|
||||
'"Segoe UI"',
|
||||
'"Noto Sans SC"',
|
||||
'HarmonyOS_Regular',
|
||||
'"Microsoft YaHei"',
|
||||
'"Helvetica Neue"',
|
||||
'Helvetica',
|
||||
'"Source Han Sans SC"',
|
||||
@@ -98,6 +98,7 @@ const BLOG = {
|
||||
'https://npm.elemecdn.com/prism-themes/themes/prism-a11y-dark.min.css', // 代码样式主题 更多参考 https://github.com/PrismJS/prism-themes
|
||||
CODE_MAC_BAR: process.env.NEXT_PUBLIC_CODE_MAC_BAR || true, // 代码左上角显示mac的红黄绿图标
|
||||
CODE_LINE_NUMBERS: process.env.NEXT_PUBLIC_CODE_LINE_NUMBERS || 'false', // 是否显示行号
|
||||
CODE_COLLAPSE: process.env.NEXT_PUBLIC_CODE_COLLAPSE || 'false', // 是否折叠代码框
|
||||
|
||||
// Mermaid 图表CDN
|
||||
MERMAID_CDN: process.env.NEXT_PUBLIC_MERMAID_CDN || 'https://cdn.jsdelivr.net/npm/mermaid@10.2.2/dist/mermaid.min.js', // CDN
|
||||
@@ -126,6 +127,9 @@ const BLOG = {
|
||||
PREVIEW_CATEGORY_COUNT: 16, // 首页最多展示的分类数量,0为不限制
|
||||
PREVIEW_TAG_COUNT: 16, // 首页最多展示的标签数量,0为不限制
|
||||
|
||||
POST_DISABLE_GALLERY_CLICK: process.env.NEXT_PUBLIC_POST_DISABLE_GALLERY_CLICK || false, // 画册视图禁止点击,方便在友链页面的画册插入链接
|
||||
|
||||
// ********动态特效相关********
|
||||
// 鼠标点击烟花特效
|
||||
FIREWORKS: process.env.NEXT_PUBLIC_FIREWORKS || false, // 开关
|
||||
// 烟花色彩,感谢 https://github.com/Vixcity 提交的色彩
|
||||
@@ -138,18 +142,18 @@ const BLOG = {
|
||||
|
||||
// 樱花飘落特效
|
||||
SAKURA: process.env.NEXT_PUBLIC_SAKURA || false, // 开关
|
||||
|
||||
// 漂浮线段特效
|
||||
NEST: process.env.NEXT_PUBLIC_NEST || false, // 开关
|
||||
|
||||
// 动态彩带特效
|
||||
FLUTTERINGRIBBON: process.env.NEXT_PUBLIC_FLUTTERINGRIBBON || false, // 开关
|
||||
// 静态彩带特效
|
||||
RIBBON: process.env.NEXT_PUBLIC_RIBBON || false, // 开关
|
||||
|
||||
// 星空雨特效 黑夜模式才会生效
|
||||
STARRY_SKY: process.env.NEXT_PUBLIC_STARRY_SKY || false, // 开关
|
||||
|
||||
// ********挂件组件相关********
|
||||
// Chatbase
|
||||
CHATBASE_ID: process.env.NEXT_PUBLIC_CHATBASE_ID || null, // 是否显示chatbase机器人 https://www.chatbase.co/
|
||||
// 悬浮挂件
|
||||
WIDGET_PET: process.env.NEXT_PUBLIC_WIDGET_PET || true, // 是否显示宠物挂件
|
||||
WIDGET_PET_LINK:
|
||||
@@ -192,6 +196,7 @@ const BLOG = {
|
||||
MUSIC_PLAYER_METING_LRC_TYPE:
|
||||
process.env.NEXT_PUBLIC_MUSIC_PLAYER_METING_LRC_TYPE || '1', // 可选值: 3 | 1 | 0(0:禁用 lrc 歌词,1:lrc 格式的字符串,3:lrc 文件 url)
|
||||
|
||||
// ********挂件组件相关********
|
||||
// ----> 评论互动 可同时开启多个支持 WALINE VALINE GISCUS CUSDIS UTTERRANCES GITALK
|
||||
|
||||
// twikoo
|
||||
@@ -320,14 +325,16 @@ const BLOG = {
|
||||
icon: process.env.NEXT_PUBLIC_NOTION_PROPERTY_ICON || 'icon'
|
||||
},
|
||||
|
||||
// RSS
|
||||
// RSS订阅
|
||||
ENABLE_RSS: process.env.NEXT_PUBLIC_ENABLE_RSS || true, // 是否开启RSS订阅功能
|
||||
MAILCHIMP_LIST_ID: process.env.MAILCHIMP_LIST_ID || null, // 开启mailichimp邮件订阅 客户列表ID ,具体使用方法参阅文档
|
||||
MAILCHIMP_API_KEY: process.env.MAILCHIMP_API_KEY || null, // 开启mailichimp邮件订阅 APIkey
|
||||
|
||||
// 作废配置
|
||||
AVATAR: process.env.NEXT_PUBLIC_AVATAR || '/avatar.svg', // 作者头像,被notion中的ICON覆盖。若无ICON则取public目录下的avatar.png
|
||||
TITLE: process.env.NEXT_PUBLIC_TITLE || 'NotionNext BLOG', // 站点标题 ,被notion中的页面标题覆盖;此处请勿留空白,否则服务器无法编译
|
||||
HOME_BANNER_IMAGE:
|
||||
process.env.NEXT_PUBLIC_HOME_BANNER_IMAGE || './bg_image.jpg', // 首页背景大图, 会被notion中的封面图覆盖,若无封面图则会使用代码中的 /public/bg_image.jpg 文件
|
||||
process.env.NEXT_PUBLIC_HOME_BANNER_IMAGE || '/bg_image.jpg', // 首页背景大图, 会被notion中的封面图覆盖,若无封面图则会使用代码中的 /public/bg_image.jpg 文件
|
||||
DESCRIPTION:
|
||||
process.env.NEXT_PUBLIC_DESCRIPTION || '这是一个由NotionNext生成的站点', // 站点描述,被notion中的页面描述覆盖
|
||||
|
||||
|
||||
14
components/ChatBase.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import BLOG from '@/blog.config'
|
||||
|
||||
export default function ChatBase() {
|
||||
if (!BLOG.CHATBASE_ID) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return <iframe
|
||||
src={`https://www.chatbase.co/chatbot-iframe/${BLOG.CHATBASE_ID}`}
|
||||
width="100%"
|
||||
style={{ height: '100%', minHeight: '700px' }}
|
||||
frameborder="0"
|
||||
></iframe>
|
||||
}
|
||||
@@ -84,7 +84,7 @@ const Collapse = props => {
|
||||
}, [props.isOpen])
|
||||
|
||||
return (
|
||||
<div ref={ref} style={type === 'vertical' ? { height: '0px' } : { width: '0px' }} className={`${props.className} overflow-hidden duration-200 `}>
|
||||
<div ref={ref} style={type === 'vertical' ? { height: '0px', willChange: 'height' } : { width: '0px', willChange: 'width' }} className={`${props.className} overflow-hidden duration-200 `}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ const ValineComponent = dynamic(() => import('@/components/ValineComponent'), {
|
||||
ssr: false
|
||||
})
|
||||
|
||||
const Comment = ({ frontMatter }) => {
|
||||
const Comment = ({ frontMatter, className }) => {
|
||||
const router = useRouter()
|
||||
|
||||
if (isBrowser() && ('giscus' in router.query || router.query.target === 'comment')) {
|
||||
@@ -70,7 +70,7 @@ const Comment = ({ frontMatter }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div id='comment' className='comment mt-5 text-gray-800 dark:text-gray-300'>
|
||||
<div id='comment' className={`comment mt-5 text-gray-800 dark:text-gray-300 ${className || ''}`}>
|
||||
<Tabs>
|
||||
|
||||
{BLOG.COMMENT_TWIKOO_ENV_ID && (<div key='Twikoo'>
|
||||
|
||||
@@ -7,6 +7,18 @@ import BLOG from '@/blog.config'
|
||||
*/
|
||||
const CommonScript = () => {
|
||||
return (<>
|
||||
|
||||
{BLOG.CHATBASE_ID && (<>
|
||||
<script id={BLOG.CHATBASE_ID} src="https://www.chatbase.co/embed.min.js" defer/>
|
||||
<script async dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.chatbaseConfig = {
|
||||
chatbotId: "${BLOG.CHATBASE_ID}",
|
||||
}
|
||||
`
|
||||
}}/>
|
||||
</>)}
|
||||
|
||||
{BLOG.COMMENT_DAO_VOICE_ID && (<>
|
||||
{/* DaoVoice 反馈 */}
|
||||
<script async dangerouslySetInnerHTML={{
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import { saveDarkModeToCookies } from '@/themes/theme'
|
||||
import { Moon, Sun } from './HeroIcons'
|
||||
import { useImperativeHandle } from 'react'
|
||||
|
||||
/**
|
||||
* 深色模式按钮
|
||||
*/
|
||||
const DarkModeButton = (props) => {
|
||||
const { cRef, className } = props
|
||||
const { isDarkMode, updateDarkMode } = useGlobal()
|
||||
|
||||
/**
|
||||
* 对外暴露方法
|
||||
*/
|
||||
useImperativeHandle(cRef, () => {
|
||||
return {
|
||||
handleChangeDarkMode: () => {
|
||||
handleChangeDarkMode()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 用户手动设置主题
|
||||
const handleChangeDarkMode = () => {
|
||||
const newStatus = !isDarkMode
|
||||
@@ -13,8 +31,8 @@ const DarkModeButton = (props) => {
|
||||
htmlElement.classList?.add(newStatus ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
return <div onClick={handleChangeDarkMode} className={'px-1 dark:text-gray-200 text-gray-800 z-10 duration-200 text-xl hover:scale-110 cursor-pointer transform ' + props.className}>
|
||||
<i id='darkModeButton' className={`${isDarkMode ? 'far fa-sun' : 'far fa-moon'}`}/>
|
||||
</div>
|
||||
return <div onClick={handleChangeDarkMode} className={`${className || ''} flex justify-center dark:text-gray-200 text-gray-800`}>
|
||||
<div id='darkModeButton' className=' hover:scale-110 cursor-pointer transform duration-200 w-5 h-5'> {isDarkMode ? <Sun /> : <Moon />}</div>
|
||||
</div>
|
||||
}
|
||||
export default DarkModeButton
|
||||
|
||||
@@ -2,7 +2,7 @@ import BLOG from '@/blog.config'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Select from './Select'
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import { ALL_THEME } from '@/themes/theme'
|
||||
import { THEMES } from '@/themes/theme'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
/**
|
||||
@@ -16,7 +16,7 @@ const DebugPanel = () => {
|
||||
const [siteConfig, updateSiteConfig] = useState({})
|
||||
|
||||
// 主题下拉框
|
||||
const themeOptions = ALL_THEME.map(t => ({ value: t, text: t }))
|
||||
const themeOptions = THEMES?.map(t => ({ value: t, text: t }))
|
||||
|
||||
useEffect(() => {
|
||||
updateSiteConfig(Object.assign({}, BLOG))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import BLOG from 'blog.config'
|
||||
import dynamic from 'next/dynamic'
|
||||
import ChatBase from './ChatBase'
|
||||
|
||||
// import TwikooCommentCounter from '@/components/TwikooCommentCounter'
|
||||
// import { DebugPanel } from '@/components/DebugPanel'
|
||||
@@ -53,6 +54,7 @@ const ExternalPlugin = (props) => {
|
||||
{JSON.parse(BLOG.FLUTTERINGRIBBON) && <FlutteringRibbon />}
|
||||
{JSON.parse(BLOG.COMMENT_TWIKOO_COUNT_ENABLE) && <TwikooCommentCounter {...props}/>}
|
||||
{JSON.parse(BLOG.RIBBON) && <Ribbon />}
|
||||
{BLOG.CHATBASE_ID && <ChatBase />}
|
||||
<VConsole/>
|
||||
</>
|
||||
}
|
||||
|
||||
56
components/FlipCard.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
/**
|
||||
* 翻转组件
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
export default function FlipCard(props) {
|
||||
const [isFlipped, setIsFlipped] = useState(false)
|
||||
|
||||
function handleCardFlip() {
|
||||
setIsFlipped(!isFlipped)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flip-card ${isFlipped ? 'flipped' : ''}`} >
|
||||
<div className={`flip-card-front ${props.className || ''}`} onMouseEnter={handleCardFlip}>
|
||||
{props.frontContent}
|
||||
</div>
|
||||
<div className={`flip-card-back ${props.className || ''}`} onMouseLeave={handleCardFlip}>
|
||||
{props.backContent}
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.flip-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.flip-card-front,
|
||||
.flip-card-back {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.flip-card-front {
|
||||
z-index: 2;
|
||||
transform: rotateY(0);
|
||||
}
|
||||
|
||||
.flip-card-back {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.flip-card.flipped {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
components/FullScreenButton.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { isBrowser } from '@/lib/utils'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
/**
|
||||
* 全屏按钮
|
||||
* @returns
|
||||
*/
|
||||
const FullScreenButton = () => {
|
||||
const [isFullScreen, setIsFullScreen] = useState(false)
|
||||
|
||||
const handleFullScreenClick = () => {
|
||||
if (!isBrowser()) {
|
||||
return
|
||||
}
|
||||
const element = document.documentElement
|
||||
if (!isFullScreen) {
|
||||
if (element.requestFullscreen) {
|
||||
element.requestFullscreen()
|
||||
} else if (element.webkitRequestFullscreen) {
|
||||
element.webkitRequestFullscreen()
|
||||
} else if (element.mozRequestFullScreen) {
|
||||
element.mozRequestFullScreen()
|
||||
} else if (element.msRequestFullscreen) {
|
||||
element.msRequestFullscreen()
|
||||
}
|
||||
setIsFullScreen(true)
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen()
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
document.webkitExitFullscreen()
|
||||
} else if (document.mozCancelFullScreen) {
|
||||
document.mozCancelFullScreen()
|
||||
} else if (document.msExitFullscreen) {
|
||||
document.msExitFullscreen()
|
||||
}
|
||||
setIsFullScreen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={handleFullScreenClick} className='dark:text-gray-300'>
|
||||
{isFullScreen ? '退出全屏' : <i className="fa-solid fa-expand"></i>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default FullScreenButton
|
||||
@@ -2,6 +2,10 @@ import BLOG from '@/blog.config'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* 初始化谷歌广告
|
||||
* @returns
|
||||
*/
|
||||
export default function GoogleAdsense() {
|
||||
const initGoogleAdsense = () => {
|
||||
setTimeout(() => {
|
||||
@@ -21,12 +25,8 @@ export default function GoogleAdsense() {
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
router.events.on('routeChangeComplete', initGoogleAdsense)
|
||||
return () => {
|
||||
router.events.off('routeChangeComplete', initGoogleAdsense)
|
||||
}
|
||||
initGoogleAdsense()
|
||||
}, [router])
|
||||
|
||||
return null
|
||||
|
||||
94
components/HeroIcons.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @see https://heroicons.com/
|
||||
* @returns
|
||||
*/
|
||||
|
||||
export const Moon = () => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const Sun = () => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const Home = ({ className }) => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const User = ({ className }) => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const ArrowPath = ({ className }) => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const ChevronLeft = ({ className }) => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const ChevronRight = ({ className }) => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const ChevronDoubleRight = ({ className }) => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const InformationCircle = ({ className }) => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const HashTag = ({ className }) => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5l-3.9 19.5m-2.1-19.5l-3.9 19.5" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const GlobeAlt = ({ className }) => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const ArrowRightCircle = ({ className }) => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12.75 15l3-3m0 0l-3-3m3 3h-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const PlusSmall = ({ className }) => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m6-6H6" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const ArrowSmallRight = ({ className }) => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export const ArrowSmallUp = ({ className }) => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 19.5v-15m0 0l-6.75 6.75M12 4.5l6.75 6.75" />
|
||||
</svg>
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { NotionRenderer } from 'react-notion-x'
|
||||
import dynamic from 'next/dynamic'
|
||||
// import mediumZoom from '@fisch0920/medium-zoom'
|
||||
import React, { useEffect } from 'react'
|
||||
import mediumZoom from '@fisch0920/medium-zoom'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
// import { Code } from 'react-notion-x/build/third-party/code'
|
||||
import TweetEmbed from 'react-tweet-embed'
|
||||
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { mapImgUrl } from '@/lib/notion/mapImage'
|
||||
import BLOG from '@/blog.config'
|
||||
import { isBrowser } from '@/lib/utils'
|
||||
|
||||
const Code = dynamic(() =>
|
||||
import('react-notion-x/build/third-party/code').then(async (m) => {
|
||||
@@ -21,6 +23,7 @@ const Equation = dynamic(() =>
|
||||
return m.Equation
|
||||
}), { ssr: false }
|
||||
)
|
||||
|
||||
const Pdf = dynamic(
|
||||
() => import('react-notion-x/build/third-party/pdf').then((m) => m.Pdf),
|
||||
{
|
||||
@@ -51,11 +54,39 @@ const NotionPage = ({ post, className }) => {
|
||||
autoScrollToTarget()
|
||||
}, [])
|
||||
|
||||
const zoom = typeof window !== 'undefined' && mediumZoom({
|
||||
container: '.notion-viewport',
|
||||
background: 'rgba(0, 0, 0, 0.2)',
|
||||
margin: getMediumZoomMargin()
|
||||
})
|
||||
const zoomRef = useRef(zoom ? zoom.clone() : null)
|
||||
|
||||
useEffect(() => {
|
||||
// 将相册gallery下的图片加入放大功能
|
||||
if (JSON.parse(BLOG.POST_DISABLE_GALLERY_CLICK)) {
|
||||
setTimeout(() => {
|
||||
if (isBrowser()) {
|
||||
const imgList = document?.querySelectorAll('.notion-collection-card-cover img')
|
||||
if (imgList && zoomRef.current) {
|
||||
for (let i = 0; i < imgList.length; i++) {
|
||||
(zoomRef.current).attach(imgList[i])
|
||||
}
|
||||
}
|
||||
|
||||
const cards = document.getElementsByClassName('notion-collection-card')
|
||||
for (const e of cards) {
|
||||
e.removeAttribute('href')
|
||||
}
|
||||
}
|
||||
}, 800)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!post || !post.blockMap) {
|
||||
return <>{post?.summary || ''}</>
|
||||
}
|
||||
|
||||
return <div id='container' className={`mx-auto ${className}`}>
|
||||
return <div id='notion-article' className={`mx-auto ${className || ''}`}>
|
||||
<NotionRenderer
|
||||
recordMap={post.blockMap}
|
||||
mapPageUrl={mapPageUrl}
|
||||
@@ -100,4 +131,25 @@ const mapPageUrl = id => {
|
||||
return '/' + id.replace(/-/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩放
|
||||
* @returns
|
||||
*/
|
||||
function getMediumZoomMargin() {
|
||||
const width = window.innerWidth
|
||||
|
||||
if (width < 500) {
|
||||
return 8
|
||||
} else if (width < 800) {
|
||||
return 20
|
||||
} else if (width < 1280) {
|
||||
return 30
|
||||
} else if (width < 1600) {
|
||||
return 40
|
||||
} else if (width < 1920) {
|
||||
return 48
|
||||
} else {
|
||||
return 72
|
||||
}
|
||||
}
|
||||
export default NotionPage
|
||||
|
||||
@@ -15,6 +15,7 @@ import { loadExternalResource } from '@/lib/utils'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
/**
|
||||
* 代码美化相关
|
||||
* @author https://github.com/txs/
|
||||
* @returns
|
||||
*/
|
||||
@@ -31,11 +32,59 @@ const PrismMac = () => {
|
||||
}
|
||||
renderPrismMac()
|
||||
renderMermaid()
|
||||
renderCollapseCode()
|
||||
})
|
||||
}, [router])
|
||||
return <></>
|
||||
}
|
||||
|
||||
/**
|
||||
* 将代码块转为可折叠对象
|
||||
*/
|
||||
const renderCollapseCode = () => {
|
||||
if (!JSON.parse(BLOG.CODE_COLLAPSE)) {
|
||||
return
|
||||
}
|
||||
const codeBlocks = document.querySelectorAll('.code-toolbar')
|
||||
for (const codeBlock of codeBlocks) {
|
||||
// 判断当前元素是否被包裹
|
||||
if (codeBlock.closest('.collapse-wrapper')) {
|
||||
continue // 如果被包裹了,跳过当前循环
|
||||
}
|
||||
|
||||
const code = codeBlock.querySelector('code')
|
||||
const language = code.getAttribute('class').match(/language-(\w+)/)[1]
|
||||
|
||||
const collapseWrapper = document.createElement('div')
|
||||
collapseWrapper.className = 'collapse-wrapper w-full py-2'
|
||||
const panelWrapper = document.createElement('div')
|
||||
panelWrapper.className = 'border rounded-md border-indigo-500'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.className = 'flex justify-between items-center px-4 py-2 cursor-pointer select-none'
|
||||
header.innerHTML = `<h3 class="text-lg font-medium">${language}</h3><svg class="transition-all duration-200 w-5 h-5 transform rotate-0" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M6.293 6.293a1 1 0 0 1 1.414 0L10 8.586l2.293-2.293a1 1 0 0 1 1.414 1.414l-3 3a1 1 0 0 1-1.414 0l-3-3a1 1 0 0 1 0-1.414z" clip-rule="evenodd"/></svg>`
|
||||
|
||||
const panel = document.createElement('div')
|
||||
panel.className = 'invisible h-0 transition-transform duration-200 border-t border-gray-300'
|
||||
|
||||
panelWrapper.appendChild(header)
|
||||
panelWrapper.appendChild(panel)
|
||||
collapseWrapper.appendChild(panelWrapper)
|
||||
|
||||
codeBlock.parentNode.insertBefore(collapseWrapper, codeBlock)
|
||||
panel.appendChild(codeBlock)
|
||||
|
||||
header.addEventListener('click', () => {
|
||||
panel.classList.toggle('invisible')
|
||||
panel.classList.toggle('h-0')
|
||||
panel.classList.toggle('h-auto')
|
||||
header.querySelector('svg').classList.toggle('rotate-180')
|
||||
panelWrapper.classList.toggle('border-gray-300')
|
||||
panelWrapper.classList.toggle('border-indigo-500')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将mermaid语言 渲染成图片
|
||||
*/
|
||||
|
||||
@@ -14,15 +14,15 @@ const ShareBar = ({ post }) => {
|
||||
|
||||
return <div className='m-1 overflow-x-auto'>
|
||||
<div className='flex w-full md:justify-end'>
|
||||
<ShareButtons shareUrl={shareUrl} title={post.title} image={post.pageCover} body={
|
||||
post.title +
|
||||
' | ' +
|
||||
BLOG.TITLE +
|
||||
' ' +
|
||||
shareUrl +
|
||||
' ' +
|
||||
post.summary
|
||||
} />
|
||||
<ShareButtons shareUrl={shareUrl} title={post.title} image={post.pageCover} body={
|
||||
post?.title +
|
||||
' | ' +
|
||||
BLOG.TITLE +
|
||||
' ' +
|
||||
shareUrl +
|
||||
' ' +
|
||||
post?.summary
|
||||
} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import React from 'react'
|
||||
import { Draggable } from './Draggable'
|
||||
import { ALL_THEME } from '@/themes/theme'
|
||||
import { THEMES } from '@/themes/theme'
|
||||
import { useRouter } from 'next/router'
|
||||
import DarkModeButton from './DarkModeButton'
|
||||
/**
|
||||
*
|
||||
* @returns 主题切换
|
||||
@@ -22,14 +23,15 @@ const ThemeSwitch = () => {
|
||||
|
||||
return (<>
|
||||
<Draggable>
|
||||
<div id="draggableBox" style={{ left: '10px', top: '85vh' }} className="fixed text-white bg-black z-50 rounded-lg shadow-card">
|
||||
<div className="py-2 flex items-center text-sm px-2">
|
||||
<select value={theme} onChange={onSelectChange} name="cars" className='text-white bg-black uppercase cursor-pointer'>
|
||||
{ALL_THEME.map(t => {
|
||||
<div id="draggableBox" style={{ left: '10px', top: '85vh' }} className="fixed dark:text-white bg-gray-50 dark:bg-black z-50 border dark:border-gray-800 rounded-2xl shadow-card">
|
||||
<div className="p-3 flex items-center text-sm">
|
||||
<DarkModeButton className='mr-2'/>
|
||||
<select value={theme} onChange={onSelectChange} name="cars" className='appearance-none outline-none dark:text-white bg-gray-50 dark:bg-black uppercase cursor-pointer'>
|
||||
{THEMES?.map(t => {
|
||||
return <option key={t} value={t}>{t}</option>
|
||||
})}
|
||||
</select>
|
||||
<i className='fas fa-palette pl-1' />
|
||||
<i class="fa-solid fa-paintbrush pl-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
</Draggable>
|
||||
|
||||
67
components/WordCount.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* 字数统计
|
||||
* @returns
|
||||
*/
|
||||
export default function WordCount() {
|
||||
const { locale } = useGlobal()
|
||||
useEffect(() => {
|
||||
countWords()
|
||||
})
|
||||
|
||||
return <span id='wordCountWrapper' className='flex gap-3 font-light'>
|
||||
<span className='flex whitespace-nowrap items-center'>
|
||||
<i className='pl-1 pr-2 fas fa-file-word' />
|
||||
<span id='wordCount'>0</span>
|
||||
</span>
|
||||
<span className='flex whitespace-nowrap items-center'>
|
||||
<i className='mr-1 fas fa-clock' />
|
||||
<span></span>
|
||||
<span id='readTime'>0</span> {locale.COMMON.MINUTE}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新字数统计和阅读时间
|
||||
*/
|
||||
function countWords() {
|
||||
const articleText = deleteHtmlTag(document.getElementById('notion-article')?.innerHTML)
|
||||
const wordCount = fnGetCpmisWords(articleText)
|
||||
// 阅读速度 300-500每分钟
|
||||
document.getElementById('wordCount').innerHTML = wordCount
|
||||
document.getElementById('readTime').innerHTML = Math.floor(wordCount / 400) + 1
|
||||
const wordCountWrapper = document.getElementById('wordCountWrapper')
|
||||
wordCountWrapper.classList.remove('hidden')
|
||||
}
|
||||
|
||||
// 去除html标签
|
||||
function deleteHtmlTag(str) {
|
||||
if (!str) {
|
||||
return ''
|
||||
}
|
||||
str = str.replace(/<[^>]+>|&[^>]+;/g, '').trim()// 去掉所有的html标签和 之类的特殊符合
|
||||
return str
|
||||
}
|
||||
|
||||
// 用word方式计算正文字数
|
||||
function fnGetCpmisWords(str) {
|
||||
if (!str) {
|
||||
return 0
|
||||
}
|
||||
let sLen = 0
|
||||
try {
|
||||
// eslint-disable-next-line no-irregular-whitespace
|
||||
str = str.replace(/(\r\n+|\s+| +)/g, '龘')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
str = str.replace(/[\x00-\xff]/g, 'm')
|
||||
str = str.replace(/m+/g, '*')
|
||||
str = str.replace(/龘+/g, '')
|
||||
sLen = str.length
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
return sLen
|
||||
}
|
||||
@@ -9,10 +9,12 @@ export default function formatDate (date, local) {
|
||||
const d = new Date(date)
|
||||
const options = { year: 'numeric', month: 'short', day: 'numeric' }
|
||||
const res = d.toLocaleDateString(local, options)
|
||||
return local.slice(0, 2).toLowerCase() === 'zh'
|
||||
const format = local.slice(0, 2).toLowerCase() === 'zh'
|
||||
? res.replace('年', '-').replace('月', '-').replace('日', '')
|
||||
: res
|
||||
return format
|
||||
}
|
||||
|
||||
export function formatDateFmt (timestamp, fmt) {
|
||||
const date = new Date(timestamp)
|
||||
const o = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { generateLocaleDict, initLocale } from './lang'
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import BLOG from '@/blog.config'
|
||||
import { ALL_THEME, initDarkMode } from '@/themes/theme'
|
||||
import { THEMES, initDarkMode } from '@/themes/theme'
|
||||
import NProgress from 'nprogress'
|
||||
import { getQueryVariable, isBrowser } from './utils'
|
||||
|
||||
@@ -69,9 +69,9 @@ export function GlobalContextProvider({ children }) {
|
||||
|
||||
// 切换主题
|
||||
function switchTheme() {
|
||||
const currentIndex = ALL_THEME.indexOf(theme)
|
||||
const newIndex = currentIndex < ALL_THEME.length - 1 ? currentIndex + 1 : 0
|
||||
const newTheme = ALL_THEME[newIndex]
|
||||
const currentIndex = THEMES.indexOf(theme)
|
||||
const newIndex = currentIndex < THEMES.length - 1 ? currentIndex + 1 : 0
|
||||
const newTheme = THEMES[newIndex]
|
||||
const query = { ...router.query, theme: newTheme }
|
||||
router.push({ pathname: router.pathname, query })
|
||||
return newTheme
|
||||
@@ -105,9 +105,8 @@ const initTheme = () => {
|
||||
if (elements?.length > 1) {
|
||||
elements[elements.length - 1].scrollIntoView()
|
||||
// 删除前面的元素,只保留最后一个元素
|
||||
elements[0].parentNode.removeChild(elements[0])
|
||||
if (Object.prototype.hasOwnProperty.call(elements, 'pop')) {
|
||||
elements.pop()
|
||||
for (let i = 0; i < elements.length - 1; i++) {
|
||||
elements[i].parentNode.removeChild(elements[i])
|
||||
}
|
||||
}
|
||||
}, 500)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export default {
|
||||
LOCALE: 'en-US',
|
||||
NAV: {
|
||||
INDEX: 'Blog',
|
||||
INDEX: 'Home',
|
||||
RSS: 'RSS',
|
||||
SEARCH: 'Search',
|
||||
ABOUT: 'About',
|
||||
@@ -35,6 +35,7 @@ export default {
|
||||
SUBMIT: 'Submit',
|
||||
POST_TIME: 'Post on',
|
||||
LAST_EDITED_TIME: 'Last edited',
|
||||
COMMENTS: 'Comments',
|
||||
RECENT_COMMENTS: 'Recent Comments',
|
||||
DEBUG_OPEN: 'Debug',
|
||||
DEBUG_CLOSE: 'Close',
|
||||
|
||||
@@ -12,7 +12,7 @@ export default {
|
||||
COMMON: {
|
||||
MORE: '更多',
|
||||
NO_MORE: '没有更多了',
|
||||
LATEST_POSTS: '最新文章',
|
||||
LATEST_POSTS: '最新发布',
|
||||
TAGS: '标签',
|
||||
NO_TAG: 'NoTag',
|
||||
CATEGORY: '分类',
|
||||
@@ -37,6 +37,7 @@ export default {
|
||||
SUBMIT: '提交',
|
||||
POST_TIME: '发布于',
|
||||
LAST_EDITED_TIME: '最后更新',
|
||||
COMMENTS: '评论',
|
||||
RECENT_COMMENTS: '最新评论',
|
||||
DEBUG_OPEN: '开启调试',
|
||||
DEBUG_CLOSE: '关闭调试',
|
||||
@@ -47,8 +48,8 @@ export default {
|
||||
WORD_COUNT: '字数'
|
||||
},
|
||||
PAGINATION: {
|
||||
PREV: '上一页',
|
||||
NEXT: '下一页'
|
||||
PREV: '上页',
|
||||
NEXT: '下页'
|
||||
},
|
||||
SEARCH: {
|
||||
ARTICLES: '搜索文章',
|
||||
|
||||
49
lib/mailchimp.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import BLOG from '@/blog.config'
|
||||
|
||||
/**
|
||||
* 订阅邮件-服务端接口
|
||||
* @param {*} email
|
||||
* @returns
|
||||
*/
|
||||
export default function subscribeToMailchimpApi({ email, first_name = '', last_name = '' }) {
|
||||
const listId = BLOG.MAILCHIMP_LIST_ID // 替换为你的邮件列表 ID
|
||||
const apiKey = BLOG.MAILCHIMP_API_KEY // 替换为你的 API KEY
|
||||
if (!email || !listId || !apiKey) {
|
||||
return {}
|
||||
}
|
||||
const data = {
|
||||
email_address: email,
|
||||
status: 'subscribed',
|
||||
merge_fields: {
|
||||
FNAME: first_name,
|
||||
LNAME: last_name
|
||||
}
|
||||
}
|
||||
return fetch(`https://us18.api.mailchimp.com/3.0/lists/${listId}/members`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `apikey ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端接口
|
||||
* @param {*} email
|
||||
* @param {*} firstName
|
||||
* @param {*} lastName
|
||||
* @returns
|
||||
*/
|
||||
export async function subscribeToNewsletter(email, firstName, lastName) {
|
||||
const response = await fetch('/api/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email, first_name: firstName, last_name: lastName })
|
||||
})
|
||||
const data = await response.json()
|
||||
return data
|
||||
}
|
||||
@@ -102,7 +102,7 @@ function getCustomNav({ allPages }) {
|
||||
* @returns
|
||||
*/
|
||||
function getCustomMenu({ collectionData }) {
|
||||
const menuPages = collectionData.filter(post => (post.type === BLOG.NOTION_PROPERTY_NAME.type_menu || post.type === BLOG.NOTION_PROPERTY_NAME.type_sub_menu) && post.status === 'Published')
|
||||
const menuPages = collectionData.filter(post => (post?.type === BLOG.NOTION_PROPERTY_NAME.type_menu || post?.type === BLOG.NOTION_PROPERTY_NAME.type_sub_menu) && post.status === 'Published')
|
||||
const menus = []
|
||||
if (menuPages && menuPages.length > 0) {
|
||||
menuPages.forEach(e => {
|
||||
@@ -287,7 +287,7 @@ async function getDataBaseInfoByNotionAPI({ pageId, from }) {
|
||||
let postCount = 0
|
||||
// 查找所有的Post和Page
|
||||
const allPages = collectionData.filter(post => {
|
||||
if (post.type === 'Post' && post.status === 'Published') {
|
||||
if (post?.type === 'Post' && post.status === 'Published') {
|
||||
postCount++
|
||||
}
|
||||
return post && post?.slug &&
|
||||
@@ -306,10 +306,10 @@ async function getDataBaseInfoByNotionAPI({ pageId, from }) {
|
||||
const categoryOptions = getAllCategories({ allPages, categoryOptions: getCategoryOptions(schema) })
|
||||
const tagOptions = getAllTags({ allPages, tagOptions: getTagOptions(schema) })
|
||||
// 旧的菜单
|
||||
const customNav = getCustomNav({ allPages: collectionData.filter(post => post.type === 'Page' && post.status === 'Published') })
|
||||
const customNav = getCustomNav({ allPages: collectionData.filter(post => post?.type === 'Page' && post.status === 'Published') })
|
||||
// 新的菜单
|
||||
const customMenu = await getCustomMenu({ collectionData })
|
||||
const latestPosts = getLatestPosts({ allPages, from, latestPostCount: 5 })
|
||||
const latestPosts = getLatestPosts({ allPages, from, latestPostCount: 6 })
|
||||
const allNavPages = getNavPages({ allPages })
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@ import BLOG from '@/blog.config'
|
||||
* 2. UnPlash 图片可以通过api q=50 控制压缩质量 width=400 控制图片尺寸
|
||||
* @param {*} image
|
||||
*/
|
||||
const compressImage = (image, width = 400) => {
|
||||
const compressImage = (image, width = 400, quality = 50, fmt = 'webp') => {
|
||||
if (!image) {
|
||||
return null
|
||||
}
|
||||
@@ -20,11 +20,12 @@ const compressImage = (image, width = 400) => {
|
||||
// 获取URL参数
|
||||
const params = new URLSearchParams(urlObj.search)
|
||||
// 将q参数的值替换
|
||||
params.set('q', '50')
|
||||
params.set('q', quality)
|
||||
// 尺寸
|
||||
params.set('width', width)
|
||||
// 格式
|
||||
params.set('fmt', 'webp')
|
||||
params.set('fmt', fmt)
|
||||
params.set('fm', fmt)
|
||||
// 生成新的URL
|
||||
urlObj.search = params.toString()
|
||||
return urlObj.toString()
|
||||
|
||||
@@ -3,8 +3,31 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
})
|
||||
|
||||
const { THEME } = require('./blog.config')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
/**
|
||||
* 扫描指定目录下的文件夹名,用于获取当前有几个主题
|
||||
* @param {*} directory
|
||||
* @returns
|
||||
*/
|
||||
function scanSubdirectories(directory) {
|
||||
const subdirectories = []
|
||||
|
||||
fs.readdirSync(directory).forEach(file => {
|
||||
const fullPath = path.join(directory, file)
|
||||
const stats = fs.statSync(fullPath)
|
||||
|
||||
// landing主题比较特殊,不在可切换的主题中显示
|
||||
if (stats.isDirectory() && file !== 'landing') {
|
||||
subdirectories.push(file)
|
||||
}
|
||||
})
|
||||
|
||||
return subdirectories
|
||||
}
|
||||
// 扫描项目 /themes下的目录名
|
||||
const themes = scanSubdirectories(path.resolve(__dirname, 'themes'))
|
||||
module.exports = withBundleAnalyzer({
|
||||
images: {
|
||||
// 图片压缩
|
||||
@@ -68,13 +91,15 @@ module.exports = withBundleAnalyzer({
|
||||
// })
|
||||
// }
|
||||
|
||||
// console.log(path.resolve(__dirname, 'themes', THEME))
|
||||
// 动态主题:添加 resolve.alias 配置,将动态路径映射到实际路径
|
||||
config.resolve.alias['@theme-components'] = path.resolve(__dirname, 'themes', THEME)
|
||||
|
||||
return config
|
||||
},
|
||||
experimental: {
|
||||
scrollRestoration: true
|
||||
},
|
||||
publicRuntimeConfig: { // 这里的配置既可以服务端获取到,也可以在浏览器端获取到
|
||||
NODE_ENV_API: process.env.NODE_ENV_API || 'prod',
|
||||
THEMES: themes
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "notion-next",
|
||||
"version": "3.16.4",
|
||||
"version": "4.0.0",
|
||||
"homepage": "https://github.com/tangly1024/NotionNext.git",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@giscus/react": "^2.2.6",
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@next/bundle-analyzer": "^12.1.1",
|
||||
"@vercel/analytics": "^1.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
@@ -53,7 +54,6 @@
|
||||
"react-notion-x": "6.16.0",
|
||||
"react-share": "^4.4.1",
|
||||
"react-tweet-embed": "~2.0.0",
|
||||
"smoothscroll-polyfill": "^0.4.4",
|
||||
"typed.js": "^2.0.12",
|
||||
"use-ackee": "^3.0.0"
|
||||
},
|
||||
@@ -69,7 +69,7 @@
|
||||
"eslint-plugin-react": "^7.23.2",
|
||||
"next-sitemap": "^1.6.203",
|
||||
"postcss": "^8.4.20",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"webpack-bundle-analyzer": "^4.5.0"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@@ -48,7 +48,7 @@ const Slug = props => {
|
||||
})
|
||||
}
|
||||
}
|
||||
}, 5 * 1000) // 404时长 8秒
|
||||
}, 8 * 1000) // 404时长 8秒
|
||||
}
|
||||
|
||||
// 文章加密
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import 'animate.css'
|
||||
// import 'animate.css'
|
||||
import '@/styles/globals.css'
|
||||
import '@/styles/nprogress.css'
|
||||
import '@/styles/utility-patterns.css'
|
||||
|
||||
// core styles shared by all of react-notion-x (required)
|
||||
import 'react-notion-x/src/styles.css'
|
||||
@@ -10,11 +11,8 @@ import '@/styles/notion.css' // 重写部分样式
|
||||
|
||||
import { GlobalContextProvider } from '@/lib/global'
|
||||
|
||||
import { isMobile } from '@/lib/utils'
|
||||
import AOS from 'aos'
|
||||
import 'aos/dist/aos.css' // You can also use <link> for styles
|
||||
|
||||
import smoothscroll from 'smoothscroll-polyfill'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
// 自定义样式css和js引入
|
||||
@@ -26,9 +24,6 @@ const ExternalPlugins = dynamic(() => import('@/components/ExternalPlugins'))
|
||||
const MyApp = ({ Component, pageProps }) => {
|
||||
useEffect(() => {
|
||||
AOS.init()
|
||||
if (isMobile()) {
|
||||
smoothscroll.polyfill()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,7 +17,7 @@ class MyDocument extends Document {
|
||||
<CommonScript />
|
||||
</Head>
|
||||
|
||||
<body className={`${BLOG.FONT_STYLE} font-light bg-day dark:bg-night`}>
|
||||
<body className={`${BLOG.FONT_STYLE} font-light`}>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
|
||||
22
pages/api/subscribe.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import subscribeToMailchimpApi from '@/lib/mailchimp'
|
||||
|
||||
/**
|
||||
* 接受邮件订阅
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
export default async function handler(req, res) {
|
||||
if (req.method === 'POST') {
|
||||
const { email, firstName, lastName } = req.body
|
||||
try {
|
||||
const response = await subscribeToMailchimpApi({ email, first_name: firstName, last_name: lastName })
|
||||
const data = await response.json()
|
||||
console.log('data', data)
|
||||
res.status(200).json({ status: 'success', message: 'Subscription successful!' })
|
||||
} catch (error) {
|
||||
res.status(400).json({ status: 'error', message: 'Subscription failed!', error })
|
||||
}
|
||||
} else {
|
||||
res.status(405).json({ status: 'error', message: 'Method not allowed' })
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { getGlobalData } from '@/lib/notion/getNotionData'
|
||||
import React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import BLOG from '@/blog.config'
|
||||
import { useRouter } from 'next/router'
|
||||
import { getLayoutByTheme } from '@/themes/theme'
|
||||
import { isBrowser } from '@/lib/utils'
|
||||
import { formatDateFmt } from '@/lib/formatDate'
|
||||
|
||||
const ArchiveIndex = props => {
|
||||
@@ -13,6 +14,20 @@ const ArchiveIndex = props => {
|
||||
// 根据页面路径加载不同Layout文件
|
||||
const Layout = getLayoutByTheme(useRouter())
|
||||
|
||||
useEffect(() => {
|
||||
if (isBrowser()) {
|
||||
const anchor = window.location.hash
|
||||
if (anchor) {
|
||||
setTimeout(() => {
|
||||
const anchorElement = document.getElementById(anchor.substring(1))
|
||||
if (anchorElement) {
|
||||
anchorElement.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const meta = {
|
||||
title: `${locale.NAV.ARCHIVE} | ${siteInfo?.title}`,
|
||||
description: siteInfo?.description,
|
||||
|
||||
@@ -117,7 +117,7 @@ async function filterByMemCache(allPosts, keyword) {
|
||||
for (const post of allPosts) {
|
||||
const cacheKey = 'page_block_' + post.id
|
||||
const page = await getDataFromCache(cacheKey, true)
|
||||
const tagContent = post.tags && Array.isArray(post.tags) ? post.tags.join(' ') : ''
|
||||
const tagContent = post?.tags && Array.isArray(post?.tags) ? post?.tags.join(' ') : ''
|
||||
const categoryContent = post.category && Array.isArray(post.category) ? post.category.join(' ') : ''
|
||||
const articleInfo = post.title + post.summary + tagContent + categoryContent
|
||||
let hit = articleInfo.toLowerCase().indexOf(keyword) > -1
|
||||
|
||||
@@ -115,7 +115,7 @@ async function filterByMemCache(allPosts, keyword) {
|
||||
for (const post of allPosts) {
|
||||
const cacheKey = 'page_block_' + post.id
|
||||
const page = await getDataFromCache(cacheKey, true)
|
||||
const tagContent = post.tags && Array.isArray(post.tags) ? post.tags.join(' ') : ''
|
||||
const tagContent = post?.tags && Array.isArray(post?.tags) ? post?.tags.join(' ') : ''
|
||||
const categoryContent = post.category && Array.isArray(post.category) ? post.category.join(' ') : ''
|
||||
const articleInfo = post.title + post.summary + tagContent + categoryContent
|
||||
let hit = articleInfo.indexOf(keyword) > -1
|
||||
|
||||
@@ -4,6 +4,11 @@ import { useRouter } from 'next/router'
|
||||
import BLOG from '@/blog.config'
|
||||
import { getLayoutByTheme } from '@/themes/theme'
|
||||
|
||||
/**
|
||||
* 搜索路由
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const Search = props => {
|
||||
const { posts, siteInfo } = props
|
||||
const { locale } = useGlobal()
|
||||
@@ -18,7 +23,7 @@ const Search = props => {
|
||||
// 静态过滤
|
||||
if (keyword) {
|
||||
filteredPosts = posts.filter(post => {
|
||||
const tagContent = post.tags ? post.tags.join(' ') : ''
|
||||
const tagContent = post?.tags ? post?.tags.join(' ') : ''
|
||||
const categoryContent = post.category ? post.category.join(' ') : ''
|
||||
const searchContent =
|
||||
post.title + post.summary + tagContent + categoryContent
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function getStaticProps({ params: { tag } }) {
|
||||
const props = await getGlobalData({ from })
|
||||
|
||||
// 过滤状态
|
||||
props.posts = props.allPages.filter(page => page.type === 'Post' && page.status === 'Published').filter(post => post && post.tags && post.tags.includes(tag))
|
||||
props.posts = props.allPages.filter(page => page.type === 'Post' && page.status === 'Published').filter(post => post && post?.tags && post?.tags.includes(tag))
|
||||
|
||||
// 处理文章页数
|
||||
props.postCount = props.posts.length
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function getStaticProps({ params: { tag, page } }) {
|
||||
const from = 'tag-page-props'
|
||||
const props = await getGlobalData({ from })
|
||||
// 过滤状态、标签
|
||||
props.posts = props.allPages.filter(page => page.type === 'Post' && page.status === 'Published').filter(post => post && post.tags && post.tags.includes(tag))
|
||||
props.posts = props.allPages.filter(page => page.type === 'Post' && page.status === 'Published').filter(post => post && post?.tags && post?.tags.includes(tag))
|
||||
// 处理文章数
|
||||
props.postCount = props.posts.length
|
||||
// 处理分页
|
||||
@@ -48,7 +48,7 @@ export async function getStaticPaths() {
|
||||
const paths = []
|
||||
tagOptions?.forEach(tag => {
|
||||
// 过滤状态类型
|
||||
const tagPosts = allPages.filter(page => page.type === 'Post' && page.status === 'Published').filter(post => post && post.tags && post.tags.includes(tag.name))
|
||||
const tagPosts = allPages.filter(page => page.type === 'Post' && page.status === 'Published').filter(post => post && post?.tags && post?.tags.includes(tag.name))
|
||||
// 处理文章页数
|
||||
const postCount = tagPosts.length
|
||||
const totalPages = Math.ceil(postCount / BLOG.POSTS_PER_PAGE)
|
||||
|
||||
BIN
public/bg_image.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 65 KiB |
@@ -1 +0,0 @@
|
||||
/* fukasawa的主题相关 */
|
||||
@@ -1,30 +0,0 @@
|
||||
/* 菜单下划线动画 */
|
||||
#theme-hexo .menu-link {
|
||||
text-decoration: none;
|
||||
background-image: linear-gradient(#928CEE, #928CEE);
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom center;
|
||||
background-size: 0 2px;
|
||||
transition: background-size 100ms ease-in-out;
|
||||
}
|
||||
|
||||
#theme-hexo .menu-link:hover {
|
||||
background-size: 100% 2px;
|
||||
color: #928CEE;
|
||||
}
|
||||
|
||||
/* 设置了从上到下的渐变黑色 */
|
||||
#theme-hexo .header-cover::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.2) 10%, rgba(0,0,0,0) 25%, rgba(0,0,0,0.2) 75%, rgba(0,0,0,0.5) 100%);
|
||||
}
|
||||
|
||||
/* Custem */
|
||||
.tk-footer{
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
|
||||
/* 设置了从上到下的渐变黑色 */
|
||||
#theme-matery .header-cover::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.2) 10%, rgba(0,0,0,0) 25%, rgba(0,0,0,0.2) 75%, rgba(0,0,0,0.5) 100%);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
#theme-simple #announcement-content {
|
||||
/* background-color: #f6f6f6; */
|
||||
}
|
||||
|
||||
#theme-simple .blog-item-title {
|
||||
color: #276077;
|
||||
}
|
||||
|
||||
.dark #theme-simple .blog-item-title {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.notion {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
/* 菜单下划线动画 */
|
||||
#theme-simple .menu-link {
|
||||
text-decoration: none;
|
||||
background-image: linear-gradient(#dd3333, #dd3333);
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom center;
|
||||
background-size: 0 2px;
|
||||
transition: background-size 100ms ease-in-out;
|
||||
}
|
||||
|
||||
#theme-simple .menu-link:hover {
|
||||
background-size: 100% 2px;
|
||||
color: #dd3333;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
BIN
public/images/feature-1.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/images/feature-2.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/images/feature-3.webp
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
public/images/features-bg.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/images/features-element.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/images/hero-image.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/images/home.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/images/testimonial.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/videos/video.mp4
Normal file
@@ -2,28 +2,6 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
--scrollbarBG: #ffffff00;
|
||||
--thumbBG: #b8b8b8;
|
||||
}
|
||||
body::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
body {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--thumbBG) var(--scrollbarBG);
|
||||
}
|
||||
body::-webkit-scrollbar-track {
|
||||
background: var(--scrollbarBG);
|
||||
}
|
||||
body::-webkit-scrollbar-thumb {
|
||||
background-color: var(--thumbBG);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(45, 170, 219, 0.3);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
@@ -285,66 +263,3 @@ a.avatar-wrapper {
|
||||
.reply-author-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.p-4-lines {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.p-3-lines {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* fukasawa的首页响应式分栏 */
|
||||
#theme-fukasawa .grid-item {
|
||||
height: auto;
|
||||
break-inside: avoid-column;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
/* 大屏幕(宽度≥1024px)下显示3列 */
|
||||
@media (min-width: 1024px) {
|
||||
#theme-fukasawa .grid-container {
|
||||
column-count: 3;
|
||||
column-gap: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕(宽度≥640px)下显示2列 */
|
||||
@media (min-width: 640px) and (max-width: 1023px) {
|
||||
#theme-fukasawa .grid-container {
|
||||
column-count: 2;
|
||||
column-gap: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端(宽度<640px)下显示1列 */
|
||||
@media (max-width: 639px) {
|
||||
#theme-fukasawa .grid-container {
|
||||
column-count: 1;
|
||||
column-gap: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.notion-external-title {
|
||||
@apply dark:text-white !important;
|
||||
}
|
||||
|
||||
.notion-external-subtitle {
|
||||
@apply dark:text-gray-400 !important;
|
||||
}
|
||||
|
||||
.notion-external-block {
|
||||
@apply dark:border-gray-200 !important;
|
||||
}
|
||||
|
||||
.notion-external-image > svg > g > path{
|
||||
@apply dark:fill-gray-200 !important;
|
||||
}
|
||||
@@ -179,9 +179,6 @@
|
||||
color: var(--select-color-2) !important;
|
||||
}
|
||||
|
||||
.notion-simple-table {
|
||||
@apply whitespace-nowrap overflow-x-auto block
|
||||
}
|
||||
|
||||
.notion-app {
|
||||
position: relative;
|
||||
@@ -446,6 +443,7 @@ summary > .notion-h {
|
||||
|
||||
.notion-h:hover .notion-hash-link {
|
||||
opacity: 1;
|
||||
@apply dark:fill-gray-200
|
||||
}
|
||||
|
||||
.notion-hash-link {
|
||||
@@ -1939,25 +1937,13 @@ svg + .notion-page-title-text {
|
||||
}
|
||||
|
||||
.notion-simple-table {
|
||||
width: 100% !important;
|
||||
@apply whitespace-nowrap overflow-x-auto block w-full border-0 !important;
|
||||
}
|
||||
|
||||
.notion-asset-wrapper-pdf > div {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-blue-500 text-gray-50 !important;
|
||||
}
|
||||
|
||||
.dark img{
|
||||
@apply opacity-80
|
||||
}
|
||||
|
||||
.dark #live2d {
|
||||
@apply opacity-80
|
||||
}
|
||||
|
||||
/* https://github.com/kchen0x */
|
||||
.notion-quote {
|
||||
display: block;
|
||||
@@ -2026,3 +2012,38 @@ code.language-mermaid {
|
||||
.notion-equation-inline .katex-display {
|
||||
margin: 0 0 !important;
|
||||
}
|
||||
|
||||
.notion-external-title {
|
||||
@apply dark:text-white !important;
|
||||
}
|
||||
|
||||
.notion-external-subtitle {
|
||||
@apply dark:text-gray-400 !important;
|
||||
}
|
||||
|
||||
.notion-external-block {
|
||||
@apply dark:border-gray-200 !important;
|
||||
}
|
||||
|
||||
.notion-external-image > svg > g > path{
|
||||
@apply dark:fill-gray-200 !important;
|
||||
}
|
||||
|
||||
.notion-external-image {
|
||||
@apply w-6 h-6 mx-3 my-2 !important;
|
||||
}
|
||||
|
||||
/* 表格 #f5f6f8*/
|
||||
.notion-simple-table-row {
|
||||
}
|
||||
|
||||
/* 表格头 */
|
||||
.notion-simple-table tr:first-child td{
|
||||
background-color: #f5f6f8;
|
||||
@apply text-center font-bold dark:bg-gray-800 !important;
|
||||
|
||||
}
|
||||
|
||||
.notion-simple-table td{
|
||||
border: 1px solid var(#eee) !important
|
||||
}
|
||||
79
styles/utility-patterns.css
Normal file
@@ -0,0 +1,79 @@
|
||||
/* Typography */
|
||||
.h1 {
|
||||
@apply text-4xl font-extrabold leading-tight tracking-tighter;
|
||||
}
|
||||
|
||||
.h2 {
|
||||
@apply text-3xl font-extrabold leading-tight tracking-tighter;
|
||||
}
|
||||
|
||||
.h3 {
|
||||
@apply text-3xl font-bold leading-tight;
|
||||
}
|
||||
|
||||
.h4 {
|
||||
@apply text-2xl font-bold leading-snug tracking-tight;
|
||||
}
|
||||
|
||||
@screen md {
|
||||
.h1 {
|
||||
@apply text-5xl;
|
||||
}
|
||||
|
||||
.h2 {
|
||||
@apply text-4xl;
|
||||
}
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn,
|
||||
.btn-sm {
|
||||
@apply font-medium inline-flex items-center justify-center border border-transparent rounded leading-snug transition duration-150 ease-in-out;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-8 py-3 shadow-lg;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply px-4 py-2 shadow;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-multiselect,
|
||||
.form-select,
|
||||
.form-checkbox,
|
||||
.form-radio {
|
||||
@apply bg-white border border-gray-300 focus:border-gray-500;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-multiselect,
|
||||
.form-select,
|
||||
.form-checkbox {
|
||||
@apply rounded;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-multiselect,
|
||||
.form-select {
|
||||
@apply py-3 px-4;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
@apply placeholder-gray-500;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
@apply pr-10;
|
||||
}
|
||||
|
||||
.form-checkbox,
|
||||
.form-radio {
|
||||
@apply text-gray-800 rounded-sm;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import LayoutBase from './LayoutBase'
|
||||
|
||||
export const Layout404 = (props) => {
|
||||
return <LayoutBase {...props}>
|
||||
404 Not found.
|
||||
</LayoutBase>
|
||||
}
|
||||
|
||||
export default Layout404
|
||||
@@ -1,47 +0,0 @@
|
||||
import BLOG from '@/blog.config'
|
||||
import Link from 'next/link'
|
||||
import LayoutBase from './LayoutBase'
|
||||
|
||||
export const LayoutArchive = props => {
|
||||
const { archivePosts } = props
|
||||
|
||||
return (
|
||||
<LayoutBase {...props}>
|
||||
<div className="mb-10 pb-20 md:py-12 p-3 min-h-screen w-full">
|
||||
{Object.keys(archivePosts).map(archiveTitle => (
|
||||
<div key={archiveTitle}>
|
||||
<div id={archiveTitle} className="pt-16 pb-4 text-3xl dark:text-gray-300" >
|
||||
{archiveTitle}
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{archivePosts[archiveTitle].map(post => (
|
||||
<li
|
||||
key={post.id}
|
||||
className="border-l-2 p-1 text-xs md:text-base items-center hover:scale-x-105 hover:border-gray-500 dark:hover:border-gray-300 dark:border-gray-400 transform duration-500"
|
||||
>
|
||||
<div id={post?.publishTime}>
|
||||
<span className="text-gray-400">
|
||||
{post.date?.start_date}
|
||||
</span>{' '}
|
||||
|
||||
<Link
|
||||
href={`${BLOG.SUB_PATH}/${post.slug}`}
|
||||
passHref
|
||||
className="dark:text-gray-400 dark:hover:text-gray-300 overflow-x-hidden hover:underline cursor-pointer text-gray-600">
|
||||
|
||||
{post.title}
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</LayoutBase>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutArchive
|
||||
@@ -1,60 +0,0 @@
|
||||
import CommonHead from '@/components/CommonHead'
|
||||
import React from 'react'
|
||||
import { Header } from './components/Header'
|
||||
import { Nav } from './components/Nav'
|
||||
import { Footer } from './components/Footer'
|
||||
import { Title } from './components/Title'
|
||||
import { SideBar } from './components/SideBar'
|
||||
import JumpToTopButton from './components/JumpToTopButton'
|
||||
import BLOG from '@/blog.config'
|
||||
import { useGlobal } from '@/lib/global'
|
||||
/**
|
||||
* 基础布局 采用左右两侧布局,移动端使用顶部导航栏
|
||||
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const LayoutBase = props => {
|
||||
const { children, meta } = props
|
||||
const { onLoading } = useGlobal()
|
||||
|
||||
const LoadingCover = <div id='cover-loading' className={`${onLoading ? 'z-50 opacity-50' : '-z-10 opacity-0'} pointer-events-none transition-all duration-300`}>
|
||||
<div className='w-full h-screen flex justify-center items-center'>
|
||||
<i className="fa-solid fa-spinner text-2xl text-black dark:text-white animate-spin"> </i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div id='theme-example' className='dark:text-gray-300 bg-white dark:bg-black'>
|
||||
<CommonHead meta={meta} />
|
||||
{/* 顶栏LOGO */}
|
||||
<Header {...props} />
|
||||
|
||||
{/* 菜单 */}
|
||||
<Nav {...props} />
|
||||
|
||||
{/* 主体 */}
|
||||
<div id='container-inner' className="w-full relative z-10">
|
||||
|
||||
<Title {...props} />
|
||||
|
||||
<div className={(BLOG.LAYOUT_SIDEBAR_REVERSE ? 'flex-row-reverse' : '') + 'relative container mx-auto justify-center md:flex items-start py-8 px-2'}>
|
||||
|
||||
<div className='w-full max-w-3xl xl:px-14 lg:px-4 '> {onLoading ? LoadingCover : children}</div>
|
||||
|
||||
<SideBar {...props} />
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<Footer {...props} />
|
||||
|
||||
<div className='fixed right-4 bottom-4 z-10'>
|
||||
<JumpToTopButton />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutBase
|
||||
@@ -1,12 +0,0 @@
|
||||
import BLOG from '@/blog.config'
|
||||
import { BlogListPage } from './components/BlogListPage'
|
||||
import { BlogListScroll } from './components/BlogListScroll'
|
||||
import LayoutBase from './LayoutBase'
|
||||
|
||||
export const LayoutCategory = props => {
|
||||
return <LayoutBase {...props}>
|
||||
{BLOG.POST_LIST_STYLE === 'page' ? <BlogListPage {...props} /> : <BlogListScroll {...props} />}
|
||||
</LayoutBase >
|
||||
}
|
||||
|
||||
export default LayoutCategory
|
||||
@@ -1,28 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
import LayoutBase from './LayoutBase'
|
||||
|
||||
export const LayoutCategoryIndex = props => {
|
||||
const { categoryOptions } = props
|
||||
return (
|
||||
<LayoutBase {...props}>
|
||||
<div id='category-list' className='duration-200 flex flex-wrap'>
|
||||
{categoryOptions?.map(category => {
|
||||
return (
|
||||
<Link
|
||||
key={category.name}
|
||||
href={`/category/${category.name}`}
|
||||
passHref
|
||||
legacyBehavior>
|
||||
<div
|
||||
className={'hover:text-black dark:hover:text-white dark:text-gray-300 dark:hover:bg-gray-600 px-5 cursor-pointer py-2 hover:bg-gray-100'}>
|
||||
<i className='mr-4 fas fa-folder' />{category.name}({category.count})
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</LayoutBase>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutCategoryIndex
|
||||
@@ -1,15 +0,0 @@
|
||||
|
||||
import BLOG from '@/blog.config'
|
||||
import { BlogListPage } from './components/BlogListPage'
|
||||
import { BlogListScroll } from './components/BlogListScroll'
|
||||
import LayoutBase from './LayoutBase'
|
||||
|
||||
export const LayoutIndex = props => {
|
||||
return (
|
||||
<LayoutBase {...props}>
|
||||
{BLOG.POST_LIST_STYLE === 'page' ? <BlogListPage {...props} /> : <BlogListScroll {...props} />}
|
||||
</LayoutBase>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutIndex
|
||||
@@ -1,12 +0,0 @@
|
||||
import { BlogListPage } from './components/BlogListPage'
|
||||
import LayoutBase from './LayoutBase'
|
||||
|
||||
export const LayoutPage = props => {
|
||||
return (
|
||||
<LayoutBase {...props}>
|
||||
<BlogListPage {...props} />
|
||||
</LayoutBase>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutPage
|
||||
@@ -1,56 +0,0 @@
|
||||
import BLOG from '@/blog.config'
|
||||
import { BlogListPage } from './components/BlogListPage'
|
||||
import { BlogListScroll } from './components/BlogListScroll'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
import SearchInput from './components/SearchInput'
|
||||
import Mark from 'mark.js'
|
||||
import LayoutBase from './LayoutBase'
|
||||
import { isBrowser } from '@/lib/utils'
|
||||
|
||||
const LayoutSearch = props => {
|
||||
const { keyword } = props
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const container = isBrowser() && document.getElementById('container')
|
||||
if (container && container.innerHTML) {
|
||||
const re = new RegExp(keyword, 'gim')
|
||||
const instance = new Mark(container)
|
||||
instance.markRegExp(re, {
|
||||
element: 'span',
|
||||
className: 'text-red-500 border-b border-dashed'
|
||||
})
|
||||
}
|
||||
}, 100)
|
||||
}, [router.events])
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (keyword) {
|
||||
const targets = document.getElementsByClassName('replace')
|
||||
for (const container of targets) {
|
||||
if (container && container.innerHTML) {
|
||||
const re = new RegExp(`${keyword}`, 'gim')
|
||||
container.innerHTML = container.innerHTML.replace(
|
||||
re,
|
||||
`<span class='text-red-500 border-b border-dashed'>${keyword}</span>`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
return <LayoutBase {...props}>
|
||||
<div className='pb-12'>
|
||||
<SearchInput {...props} />
|
||||
</div>
|
||||
|
||||
{BLOG.POST_LIST_STYLE === 'page' ? <BlogListPage {...props} /> : <BlogListScroll {...props} />}
|
||||
|
||||
</LayoutBase>
|
||||
}
|
||||
|
||||
export default LayoutSearch
|
||||
@@ -1,33 +0,0 @@
|
||||
import LayoutBase from './LayoutBase'
|
||||
import { ArticleLock } from './components/ArticleLock'
|
||||
import NotionPage from '@/components/NotionPage'
|
||||
import { ArticleInfo } from './components/ArticleInfo'
|
||||
import Comment from '@/components/Comment'
|
||||
import ShareBar from '@/components/ShareBar'
|
||||
|
||||
export const LayoutSlug = props => {
|
||||
const { post, lock, validPassword } = props
|
||||
|
||||
if (!post) {
|
||||
return <LayoutBase {...props} />
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutBase {...props}>
|
||||
|
||||
{lock && <ArticleLock validPassword={validPassword} />}
|
||||
|
||||
{!lock && <div id="notion-article" className="px-2">
|
||||
{post && <>
|
||||
<ArticleInfo post={post} />
|
||||
<NotionPage post={post} />
|
||||
<ShareBar post={post} />
|
||||
<Comment frontMatter={post}/>
|
||||
</>}
|
||||
</div>}
|
||||
|
||||
</LayoutBase>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutSlug
|
||||
@@ -1,12 +0,0 @@
|
||||
import BLOG from '@/blog.config'
|
||||
import { BlogListPage } from './components/BlogListPage'
|
||||
import { BlogListScroll } from './components/BlogListScroll'
|
||||
import LayoutBase from './LayoutBase'
|
||||
|
||||
export const LayoutTag = props => {
|
||||
return <LayoutBase {...props}>
|
||||
{BLOG.POST_LIST_STYLE === 'page' ? <BlogListPage {...props} /> : <BlogListScroll {...props} />}
|
||||
</LayoutBase >
|
||||
}
|
||||
|
||||
export default LayoutTag
|
||||
@@ -1,31 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
import LayoutBase from './LayoutBase'
|
||||
|
||||
export const LayoutTagIndex = (props) => {
|
||||
const { tagOptions } = props
|
||||
return (
|
||||
<LayoutBase {...props}>
|
||||
<div>
|
||||
<div id='tags-list' className='duration-200 flex flex-wrap'>
|
||||
{tagOptions.map(tag => {
|
||||
return (
|
||||
<div key={tag.name} className='p-2'>
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/tag/${encodeURIComponent(tag.name)}`}
|
||||
passHref
|
||||
className={`cursor-pointer inline-block rounded hover:bg-gray-500 hover:text-white duration-200
|
||||
mr-2 py-1 px-2 text-xs whitespace-nowrap dark:hover:text-white text-gray-600 hover:shadow-xl dark:border-gray-400 notion-${tag.color}_background dark:bg-gray-800`}>
|
||||
|
||||
<div className='font-light dark:text-gray-400'><i className='mr-1 fas fa-tag' /> {tag.name + (tag.count ? `(${tag.count})` : '')} </div>
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div> </LayoutBase>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutTagIndex
|
||||
@@ -5,7 +5,7 @@ const NotionPage = dynamic(() => import('@/components/NotionPage'))
|
||||
|
||||
const Announcement = ({ post, className }) => {
|
||||
const { locale } = useGlobal()
|
||||
if (!post) {
|
||||
if (!post || Object.keys(post).length === 0) {
|
||||
return <></>
|
||||
}
|
||||
return <aside className="rounded shadow overflow-hidden mb-6">
|
||||
|
||||
@@ -11,12 +11,12 @@ export const ArticleInfo = (props) => {
|
||||
<div>
|
||||
{post?.type !== 'Page' && <>
|
||||
<Link
|
||||
href={`/category/${post.category}`}
|
||||
href={`/category/${post?.category}`}
|
||||
passHref
|
||||
className="cursor-pointer text-md mr-2 hover:text-black dark:hover:text-white border-b dark:border-gray-500 border-dashed">
|
||||
|
||||
<i className="mr-1 fas fa-folder-open" />
|
||||
{post.category}
|
||||
{post?.category}
|
||||
|
||||
</Link>
|
||||
<span className='mr-2'>|</span>
|
||||
@@ -33,7 +33,7 @@ export const ArticleInfo = (props) => {
|
||||
</Link>
|
||||
<span className='mr-2'>|</span>
|
||||
<span className='mx-2 text-gray-400 dark:text-gray-500'>
|
||||
{locale.COMMON.LAST_EDITED_TIME}: {post.lastEditedTime}
|
||||
{locale.COMMON.LAST_EDITED_TIME}: {post?.lastEditedTime}
|
||||
</span>
|
||||
<span className='mr-2'>|</span>
|
||||
<span className="hidden busuanzi_container_page_pv font-light mr-2">
|
||||
|
||||
35
themes/example/components/BlogListGroupByDate.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import BLOG from '@/blog.config'
|
||||
import Link from 'next/link'
|
||||
|
||||
/**
|
||||
* 按照日期将文章分组
|
||||
* 归档页面用到
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
export default function BlogListGroupByDate({ archiveTitle, archivePosts }) {
|
||||
return <div key={archiveTitle}>
|
||||
<div id={archiveTitle} className="pt-16 pb-4 text-3xl dark:text-gray-300" >
|
||||
{archiveTitle}
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{archivePosts[archiveTitle].map(post => (
|
||||
<li
|
||||
key={post.id}
|
||||
className="border-l-2 p-1 text-xs md:text-base items-center hover:scale-x-105 hover:border-gray-500 dark:hover:border-gray-300 dark:border-gray-400 transform duration-500"
|
||||
>
|
||||
<div id={post?.publishTime}>
|
||||
<span className="text-gray-400">
|
||||
{post?.publishTime}
|
||||
</span>{' '}
|
||||
|
||||
<Link href={`${BLOG.SUB_PATH}/${post.slug}`} className="dark:text-gray-400 dark:hover:text-gray-300 overflow-x-hidden hover:underline cursor-pointer text-gray-600">
|
||||
{post.title}
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import BLOG from '@/blog.config'
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import { useRouter } from 'next/router'
|
||||
import Link from 'next/link'
|
||||
import CONFIG_EXAMPLE from '../config_example'
|
||||
import CONFIG from '../config'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
|
||||
export const BlogListPage = props => {
|
||||
@@ -17,12 +17,12 @@ export const BlogListPage = props => {
|
||||
const showNext = page < totalPage
|
||||
const pagePrefix = router.asPath.split('?')[0].replace(/\/page\/[1-9]\d*/, '').replace(/\/$/, '')
|
||||
|
||||
const showPageCover = CONFIG_EXAMPLE.POST_LIST_COVER
|
||||
const showPageCover = CONFIG.POST_LIST_COVER
|
||||
|
||||
return (
|
||||
<div className={`w-full ${showPageCover ? 'md:pr-2' : 'md:pr-12'}} mb-12`}>
|
||||
<div className={`w-full ${showPageCover ? 'md:pr-2' : 'md:pr-12'} mb-12`}>
|
||||
|
||||
<div id="container">
|
||||
<div id="posts-wrapper">
|
||||
{posts?.map(post => (
|
||||
<BlogPostCard key={post.id} post = {post}/>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useGlobal } from '@/lib/global'
|
||||
import React, { useEffect } from 'react'
|
||||
import throttle from 'lodash.throttle'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import CONFIG_EXAMPLE from '../config_example'
|
||||
import CONFIG from '../config'
|
||||
|
||||
export const BlogListScroll = props => {
|
||||
const { posts } = props
|
||||
@@ -35,7 +35,7 @@ export const BlogListScroll = props => {
|
||||
handleGetMore()
|
||||
}
|
||||
}, 500))
|
||||
const showPageCover = CONFIG_EXAMPLE.POST_LIST_COVER
|
||||
const showPageCover = CONFIG.POST_LIST_COVER
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('scroll', scrollTrigger)
|
||||
@@ -47,7 +47,7 @@ export const BlogListScroll = props => {
|
||||
|
||||
return (
|
||||
|
||||
<div className={`w-full ${showPageCover ? 'md:pr-2' : 'md:pr-12'}} mb-12`} ref={targetRef}>
|
||||
<div id='posts-wrapper' className={`w-full ${showPageCover ? 'md:pr-2' : 'md:pr-12'}} mb-12`} ref={targetRef}>
|
||||
|
||||
{postsToShow?.map(post => (
|
||||
<BlogPostCard key={post.id} post={post} />
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import BLOG from '@/blog.config'
|
||||
import CONFIG_EXAMPLE from '../config_example'
|
||||
import CONFIG from '../config'
|
||||
import Link from 'next/link'
|
||||
import TwikooCommentCount from '@/components/TwikooCommentCount'
|
||||
|
||||
const BlogPostCard = ({ post }) => {
|
||||
const showPageCover = CONFIG_EXAMPLE.POST_LIST_COVER
|
||||
const showPageCover = CONFIG.POST_LIST_COVER && post?.pageCoverThumbnail
|
||||
|
||||
return <article className={`mb-12 ${showPageCover ? 'flex md:flex-row flex-col-reverse' : ''}`}>
|
||||
return <article className={`${showPageCover ? 'flex md:flex-row flex-col-reverse' : ''} replace mb-12 `}>
|
||||
<div className={`${showPageCover ? 'md:w-7/12' : ''}`}>
|
||||
<h2 className="mb-4">
|
||||
<Link
|
||||
href={`/${post.slug}`}
|
||||
className="text-black dark:text-gray-100 text-xl md:text-2xl no-underline hover:underline">
|
||||
{post.title}
|
||||
{post?.title}
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
@@ -25,14 +25,14 @@ const BlogPostCard = ({ post }) => {
|
||||
{/* <a href="#" className="text-gray-700">2 Comments</a> */}
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 dark:text-gray-400 leading-normal p-3-lines">
|
||||
{!post.results && <p className="line-clamp-3 text-gray-700 dark:text-gray-400 leading-normal">
|
||||
{post.summary}
|
||||
</p>
|
||||
</p>}
|
||||
{/* 搜索结果 */}
|
||||
{post.results && (
|
||||
<p className="p-4-lines mt-4 text-gray-700 dark:text-gray-300 text-sm font-light leading-7">
|
||||
{post.results.map(r => (
|
||||
<span key={r}>{r}</span>
|
||||
<p className="line-clamp-3 mt-4 text-gray-700 dark:text-gray-300 text-sm font-light leading-7">
|
||||
{post.results.map((r, index) => (
|
||||
<span key={index}>{r}</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
20
themes/example/components/CategoryItem.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
/**
|
||||
* 文章分类
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
export default function CategoryItem({ category }) {
|
||||
return (
|
||||
<Link
|
||||
key={category.name}
|
||||
href={`/category/${category.name}`}
|
||||
passHref
|
||||
legacyBehavior>
|
||||
<div className={'hover:text-black dark:hover:text-white dark:text-gray-300 dark:hover:bg-gray-600 px-5 cursor-pointer py-2 hover:bg-gray-100'}>
|
||||
<i className='mr-4 fas fa-folder' />{category.name}({category.count})
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
8
themes/example/components/LoadingCover.js
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
export default function LoadingCover() {
|
||||
return <div id='cover-loading' className={'z-50 opacity-50 pointer-events-none transition-all duration-300'}>
|
||||
<div className='w-full h-screen flex justify-center items-center'>
|
||||
<i className="fa-solid fa-spinner text-2xl text-black dark:text-white animate-spin"> </i>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -25,8 +25,8 @@ export const MenuItemDrop = ({ link }) => {
|
||||
|
||||
{/* 子菜单 */}
|
||||
{hasSubMenu && <ul className={`${show ? 'visible opacity-100 top-12' : 'invisible opacity-0 top-10'} border-gray-100 bg-white dark:bg-black dark:border-gray-800 transition-all duration-300 z-20 absolute block drop-shadow-lg `}>
|
||||
{link.subMenus.map(sLink => {
|
||||
return <li key={sLink.id} className='not:last-child:border-b-0 border-b text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-900 tracking-widest transition-all duration-200 dark:border-gray-800 py-3 pr-6 pl-2'>
|
||||
{link.subMenus.map((sLink, index) => {
|
||||
return <li key={index} className='not:last-child:border-b-0 border-b text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-900 tracking-widest transition-all duration-200 dark:border-gray-800 py-3 pr-6 pl-3'>
|
||||
<Link href={sLink.to}>
|
||||
<span className='text-sm text-nowrap font-extralight'>{link?.icon && <i className={sLink?.icon} > </i>}{sLink.title}</span>
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import BLOG from '@/blog.config'
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import CONFIG_EXAMPLE from '../config_example'
|
||||
import CONFIG from '../config'
|
||||
import { MenuItemDrop } from './MenuItemDrop'
|
||||
|
||||
/**
|
||||
@@ -13,10 +13,10 @@ export const Nav = (props) => {
|
||||
const { locale } = useGlobal()
|
||||
|
||||
let links = [
|
||||
{ icon: 'fas fa-search', name: locale.NAV.SEARCH, to: '/search', show: CONFIG_EXAMPLE.MENU_SEARCH },
|
||||
{ icon: 'fas fa-archive', name: locale.NAV.ARCHIVE, to: '/archive', show: CONFIG_EXAMPLE.MENU_ARCHIVE },
|
||||
{ icon: 'fas fa-folder', name: locale.COMMON.CATEGORY, to: '/category', show: CONFIG_EXAMPLE.MENU_CATEGORY },
|
||||
{ icon: 'fas fa-tag', name: locale.COMMON.TAGS, to: '/tag', show: CONFIG_EXAMPLE.MENU_TAG }
|
||||
{ id: 1, icon: 'fas fa-search', name: locale.NAV.SEARCH, to: '/search', show: CONFIG.MENU_SEARCH },
|
||||
{ id: 2, icon: 'fas fa-archive', name: locale.NAV.ARCHIVE, to: '/archive', show: CONFIG.MENU_ARCHIVE },
|
||||
{ id: 3, icon: 'fas fa-folder', name: locale.COMMON.CATEGORY, to: '/category', show: CONFIG.MENU_CATEGORY },
|
||||
{ id: 4, icon: 'fas fa-tag', name: locale.COMMON.TAGS, to: '/tag', show: CONFIG.MENU_TAG }
|
||||
]
|
||||
|
||||
if (customNav) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useImperativeHandle, useRef, useState } from 'react'
|
||||
|
||||
let lock = false
|
||||
|
||||
const SearchInput = ({ currentTag, currentSearch, cRef }) => {
|
||||
const SearchInput = ({ currentTag, keyword, cRef }) => {
|
||||
const { locale } = useGlobal()
|
||||
const router = useRouter()
|
||||
const searchInputRef = useRef(null)
|
||||
@@ -68,7 +68,7 @@ const SearchInput = ({ currentTag, currentSearch, cRef }) => {
|
||||
onCompositionUpdate={lockSearchInput}
|
||||
onCompositionEnd={unLockSearchInput}
|
||||
onChange={e => updateSearchKey(e.target.value)}
|
||||
defaultValue={currentSearch || ''}
|
||||
defaultValue={keyword || ''}
|
||||
/>
|
||||
|
||||
<div className='-ml-8 cursor-pointer float-right items-center justify-center py-2'
|
||||
|
||||
18
themes/example/components/TagItem.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
/**
|
||||
* 标签
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
export default function TagItem({ tag }) {
|
||||
return <div key={tag.name} className='p-2'>
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/tag/${encodeURIComponent(tag.name)}`}
|
||||
passHref
|
||||
className={`cursor-pointer inline-block rounded hover:bg-gray-500 hover:text-white duration-200 mr-2 py-1 px-2 text-xs whitespace-nowrap dark:hover:text-white text-gray-600 hover:shadow-xl dark:border-gray-400 notion-${tag.color}_background dark:bg-gray-800`}>
|
||||
<div className='font-light dark:text-gray-400'><i className='mr-1 fas fa-tag' /> {tag.name + (tag.count ? `(${tag.count})` : '')} </div>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
const CONFIG_EXAMPLE = {
|
||||
/**
|
||||
* 主题配置文件
|
||||
*/
|
||||
const CONFIG = {
|
||||
// 菜单配置
|
||||
MENU_CATEGORY: true, // 显示分类
|
||||
MENU_TAG: true, // 显示标签
|
||||
@@ -8,4 +11,4 @@ const CONFIG_EXAMPLE = {
|
||||
POST_LIST_COVER: true // 列表显示文章封面
|
||||
|
||||
}
|
||||
export default CONFIG_EXAMPLE
|
||||
export default CONFIG
|
||||
@@ -1,25 +1,258 @@
|
||||
import CONFIG_EXAMPLE from './config_example'
|
||||
import LayoutIndex from './LayoutIndex'
|
||||
import LayoutSearch from './LayoutSearch'
|
||||
import LayoutArchive from './LayoutArchive'
|
||||
import LayoutSlug from './LayoutSlug'
|
||||
import Layout404 from './Layout404'
|
||||
import LayoutCategory from './LayoutCategory'
|
||||
import LayoutCategoryIndex from './LayoutCategoryIndex'
|
||||
import LayoutPage from './LayoutPage'
|
||||
import LayoutTag from './LayoutTag'
|
||||
import LayoutTagIndex from './LayoutTagIndex'
|
||||
'use client'
|
||||
|
||||
import BLOG from '@/blog.config'
|
||||
import CONFIG from './config'
|
||||
import CommonHead from '@/components/CommonHead'
|
||||
import { useEffect } from 'react'
|
||||
import { Header } from './components/Header'
|
||||
import { Nav } from './components/Nav'
|
||||
import { Footer } from './components/Footer'
|
||||
import { Title } from './components/Title'
|
||||
import { SideBar } from './components/SideBar'
|
||||
import { BlogListPage } from './components/BlogListPage'
|
||||
import { BlogListScroll } from './components/BlogListScroll'
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import { ArticleLock } from './components/ArticleLock'
|
||||
import { ArticleInfo } from './components/ArticleInfo'
|
||||
import JumpToTopButton from './components/JumpToTopButton'
|
||||
import NotionPage from '@/components/NotionPage'
|
||||
import Comment from '@/components/Comment'
|
||||
import ShareBar from '@/components/ShareBar'
|
||||
import SearchInput from './components/SearchInput'
|
||||
import Mark from 'mark.js'
|
||||
import { isBrowser } from '@/lib/utils'
|
||||
import BlogListGroupByDate from './components/BlogListGroupByDate'
|
||||
import CategoryItem from './components/CategoryItem'
|
||||
import TagItem from './components/TagItem'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Transition } from '@headlessui/react'
|
||||
import { Style } from './style'
|
||||
|
||||
/**
|
||||
* 基础布局框架
|
||||
* 1.其它页面都嵌入在LayoutBase中
|
||||
* 2.采用左右两侧布局,移动端使用顶部导航栏
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const LayoutBase = props => {
|
||||
const { children, meta, slotTop } = props
|
||||
const { onLoading } = useGlobal()
|
||||
|
||||
// 增加一个状态以触发 Transition 组件的动画
|
||||
// const [showTransition, setShowTransition] = useState(true)
|
||||
// useEffect(() => {
|
||||
// // 当 location 或 children 发生变化时,触发动画
|
||||
// setShowTransition(false)
|
||||
// setTimeout(() => setShowTransition(true), 5)
|
||||
// }, [onLoading])
|
||||
|
||||
return (
|
||||
<div id='theme-example' className='dark:text-gray-300 bg-white dark:bg-black'>
|
||||
{/* 网页SEO信息 */}
|
||||
<CommonHead meta={meta} />
|
||||
<Style/>
|
||||
|
||||
{/* 页头 */}
|
||||
<Header {...props} />
|
||||
|
||||
{/* 菜单 */}
|
||||
<Nav {...props} />
|
||||
|
||||
{/* 主体 */}
|
||||
<div id='container-inner' className="w-full relative z-10">
|
||||
|
||||
{/* 标题栏 */}
|
||||
<Title {...props} />
|
||||
|
||||
<div id='container-wrapper' className={(BLOG.LAYOUT_SIDEBAR_REVERSE ? 'flex-row-reverse' : '') + 'relative container mx-auto justify-center md:flex items-start py-8 px-2'}>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className='w-full max-w-3xl xl:px-14 lg:px-4 '>
|
||||
<Transition
|
||||
show={!onLoading}
|
||||
appear={true}
|
||||
enter="transition ease-in-out duration-700 transform order-first"
|
||||
enterFrom="opacity-0 translate-y-16"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in-out duration-300 transform"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 -translate-y-16"
|
||||
unmount={false}
|
||||
>
|
||||
{/* 嵌入模块 */}
|
||||
{slotTop}
|
||||
{children}
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<SideBar {...props} />
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 页脚 */}
|
||||
<Footer {...props} />
|
||||
|
||||
{/* 回顶按钮 */}
|
||||
<div className='fixed right-4 bottom-4 z-10'>
|
||||
<JumpToTopButton />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页
|
||||
* @param {*} props
|
||||
* @returns 此主题首页就是列表
|
||||
*/
|
||||
const LayoutIndex = props => {
|
||||
return <LayoutPostList {...props} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章列表
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const LayoutPostList = props => {
|
||||
const { category, tag } = props
|
||||
// 顶部如果是按照分类或标签查看文章列表,列表顶部嵌入一个横幅
|
||||
let slotTop = null
|
||||
if (category) {
|
||||
slotTop = <div className='pb-12'><i className="mr-1 fas fa-folder-open" />{category}</div>
|
||||
} else if (tag) {
|
||||
slotTop = <div className='pb-12'>#{tag}</div>
|
||||
}
|
||||
return (
|
||||
<LayoutBase {...props} slotTop={slotTop}>
|
||||
{BLOG.POST_LIST_STYLE === 'page' ? <BlogListPage {...props} /> : <BlogListScroll {...props} />}
|
||||
</LayoutBase>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章详情页
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const LayoutSlug = props => {
|
||||
const { post, lock, validPassword } = props
|
||||
return (
|
||||
<LayoutBase {...props}>
|
||||
{lock
|
||||
? <ArticleLock validPassword={validPassword} />
|
||||
: <div id="article-wrapper" className="px-2">
|
||||
<ArticleInfo post={post} />
|
||||
<NotionPage post={post} />
|
||||
<ShareBar post={post} />
|
||||
<Comment frontMatter={post} />
|
||||
</div>}
|
||||
</LayoutBase>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 404页
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const Layout404 = (props) => {
|
||||
return <LayoutBase {...props}>404 Not found.</LayoutBase>
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索页
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const LayoutSearch = props => {
|
||||
const { keyword } = props
|
||||
// 嵌入一个搜索框在顶部
|
||||
const slotTop = <div className='pb-12'><SearchInput {...props} /></div>
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (isBrowser()) {
|
||||
// 高亮搜索到的结果
|
||||
const container = document.getElementById('posts-wrapper')
|
||||
console.log('container', container, keyword)
|
||||
if (keyword && container) {
|
||||
const re = new RegExp(keyword, 'gim')
|
||||
const instance = new Mark(container)
|
||||
instance.markRegExp(re, {
|
||||
element: 'span',
|
||||
className: 'text-red-500 border-b border-dashed'
|
||||
})
|
||||
}
|
||||
}
|
||||
}, 500)
|
||||
}, [router])
|
||||
|
||||
return <LayoutPostList slotTop={slotTop} {...props} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 归档列表
|
||||
* @param {*} props
|
||||
* @returns 按照日期将文章分组排序
|
||||
*/
|
||||
const LayoutArchive = props => {
|
||||
const { archivePosts } = props
|
||||
return (
|
||||
<LayoutBase {...props}>
|
||||
<div className="mb-10 pb-20 md:py-12 p-3 min-h-screen w-full">
|
||||
{Object.keys(archivePosts).map(archiveTitle => (
|
||||
<BlogListGroupByDate key={archiveTitle} archiveTitle={archiveTitle} archivePosts={archivePosts} />
|
||||
))}
|
||||
</div>
|
||||
</LayoutBase>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类列表
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const LayoutCategoryIndex = props => {
|
||||
const { categoryOptions } = props
|
||||
return (
|
||||
<LayoutBase {...props}>
|
||||
<div id='category-list' className='duration-200 flex flex-wrap'>
|
||||
{categoryOptions?.map(category => <CategoryItem key={category.name} category={category} />)}
|
||||
</div>
|
||||
</LayoutBase>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签列表
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const LayoutTagIndex = (props) => {
|
||||
const { tagOptions } = props
|
||||
return (
|
||||
<LayoutBase {...props}>
|
||||
<div id='tags-list' className='duration-200 flex flex-wrap'>
|
||||
{tagOptions.map(tag => <TagItem key={tag.name} tag={tag} />)}
|
||||
</div>
|
||||
</LayoutBase>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
CONFIG_EXAMPLE as THEME_CONFIG,
|
||||
CONFIG as THEME_CONFIG,
|
||||
LayoutIndex,
|
||||
LayoutPostList,
|
||||
LayoutSearch,
|
||||
LayoutArchive,
|
||||
LayoutSlug,
|
||||
Layout404,
|
||||
LayoutCategory,
|
||||
LayoutCategoryIndex,
|
||||
LayoutPage,
|
||||
LayoutTag,
|
||||
LayoutTagIndex
|
||||
}
|
||||
|
||||
17
themes/example/style.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/* eslint-disable react/no-unknown-property */
|
||||
/**
|
||||
* 此处样式只对当前主题生效
|
||||
* 此处不支持tailwindCSS的 @apply 语法
|
||||
* @returns
|
||||
*/
|
||||
const Style = () => {
|
||||
return <style jsx global>{`
|
||||
// 底色
|
||||
.dark body{
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
`}</style>
|
||||
}
|
||||
|
||||
export { Style }
|
||||
@@ -1,7 +0,0 @@
|
||||
import LayoutBase from './LayoutBase'
|
||||
|
||||
export const Layout404 = props => {
|
||||
return <LayoutBase {...props}>404</LayoutBase>
|
||||
}
|
||||
|
||||
export default Layout404
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import BlogArchiveItem from './components/BlogPostArchive'
|
||||
import LayoutBase from './LayoutBase'
|
||||
|
||||
export const LayoutArchive = (props) => {
|
||||
const { archivePosts } = props
|
||||
|
||||
useEffect(() => {
|
||||
const anchor = window.location.hash
|
||||
if (anchor) {
|
||||
setTimeout(() => {
|
||||
const anchorElement = document.getElementById(anchor.substring(1))
|
||||
if (anchorElement) {
|
||||
anchorElement.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
}, [])
|
||||
return <LayoutBase {...props}>
|
||||
<div className="mb-10 pb-20 bg-white md:p-12 p-3 dark:bg-gray-800 shadow-md min-h-full">
|
||||
{Object.keys(archivePosts).map(archiveTitle => (
|
||||
<BlogArchiveItem
|
||||
key={archiveTitle}
|
||||
posts={archivePosts[archiveTitle]}
|
||||
archiveTitle={archiveTitle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</LayoutBase>
|
||||
}
|
||||
|
||||
export default LayoutArchive
|
||||
@@ -1,58 +0,0 @@
|
||||
import CommonHead from '@/components/CommonHead'
|
||||
import TopNav from './components/TopNav'
|
||||
import AsideLeft from './components/AsideLeft'
|
||||
import Live2D from '@/components/Live2D'
|
||||
import BLOG from '@/blog.config'
|
||||
import { isBrowser, loadExternalResource } from '@/lib/utils'
|
||||
import { useGlobal } from '@/lib/global'
|
||||
|
||||
/**
|
||||
* 基础布局 采用左右两侧布局,移动端使用顶部导航栏
|
||||
* @param children
|
||||
* @param layout
|
||||
* @param tags
|
||||
* @param meta
|
||||
* @param post
|
||||
* @param currentSearch
|
||||
* @param currentCategory
|
||||
* @param currentTag
|
||||
* @param categories
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const LayoutBase = (props) => {
|
||||
const { children, headerSlot, meta } = props
|
||||
const leftAreaSlot = <Live2D/>
|
||||
|
||||
if (isBrowser()) {
|
||||
loadExternalResource('/css/theme-fukasawa.css', 'css')
|
||||
}
|
||||
|
||||
const { onLoading } = useGlobal()
|
||||
|
||||
const LoadingCover = <div id='cover-loading' className={`${onLoading ? 'z-50 opacity-50' : '-z-10 opacity-0'} pointer-events-none transition-all duration-300`}>
|
||||
<div className='w-full h-screen flex justify-center items-center'>
|
||||
<i className="fa-solid fa-spinner text-2xl text-black dark:text-white animate-spin"> </i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
return (<div id='theme-fukasawa' >
|
||||
<CommonHead meta={meta} />
|
||||
<TopNav {...props}/>
|
||||
|
||||
<div className={(BLOG.LAYOUT_SIDEBAR_REVERSE ? 'flex-row-reverse' : '') + ' flex'}>
|
||||
<AsideLeft {...props} slot={leftAreaSlot}/>
|
||||
|
||||
<main id='wrapper' className='relative flex w-full py-8 justify-center bg-day dark:bg-night'>
|
||||
<div id='container-inner' className='2xl:max-w-6xl md:max-w-4xl w-full relative z-10'>
|
||||
<div> {headerSlot} </div>
|
||||
<div> {onLoading ? LoadingCover : children} </div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
</div>)
|
||||
}
|
||||
|
||||
export default LayoutBase
|
||||
@@ -1,12 +0,0 @@
|
||||
import BLOG from '@/blog.config'
|
||||
import BlogListPage from './components/BlogListPage'
|
||||
import BlogListScroll from './components/BlogListScroll'
|
||||
import LayoutBase from './LayoutBase'
|
||||
|
||||
export const LayoutCategory = props => {
|
||||
return <LayoutBase {...props}>
|
||||
{BLOG.POST_LIST_STYLE === 'page' ? <BlogListPage {...props} /> : <BlogListScroll {...props}/>}
|
||||
</LayoutBase>
|
||||
}
|
||||
|
||||
export default LayoutCategory
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import Link from 'next/link'
|
||||
import LayoutBase from './LayoutBase'
|
||||
|
||||
export const LayoutCategoryIndex = (props) => {
|
||||
const { locale } = useGlobal()
|
||||
const { categoryOptions } = props
|
||||
return (
|
||||
<LayoutBase {...props}>
|
||||
<div className='bg-white dark:bg-gray-700 px-10 py-10 shadow'>
|
||||
<div className='dark:text-gray-200 mb-5'>
|
||||
<i className='mr-4 fas fa-th' />{locale.COMMON.CATEGORY}:
|
||||
</div>
|
||||
<div id='category-list' className='duration-200 flex flex-wrap'>
|
||||
{categoryOptions?.map(category => {
|
||||
return (
|
||||
<Link
|
||||
key={category.name}
|
||||
href={`/category/${category.name}`}
|
||||
passHref
|
||||
legacyBehavior>
|
||||
<div
|
||||
className={'hover:text-black dark:hover:text-white dark:text-gray-300 dark:hover:bg-gray-600 px-5 cursor-pointer py-2 hover:bg-gray-100'}>
|
||||
<i className='mr-4 fas fa-folder' />{category.name}({category.count})
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</LayoutBase>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutCategoryIndex
|
||||