feat(theme): init project
This commit is contained in:
32
mhhung/components/ArticleAround.js
Normal file
32
mhhung/components/ArticleAround.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import SmartLink from '@/components/SmartLink'
|
||||
|
||||
/**
|
||||
* 上一篇,下一篇文章
|
||||
* @param {prev,next} param0
|
||||
* @returns
|
||||
*/
|
||||
export default function ArticleAround({ prev, next }) {
|
||||
if (!prev || !next) {
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<section className='text-gray-800 dark:text-gray-400 h-12 flex items-center justify-between space-x-5 my-4'>
|
||||
{prev && <SmartLink
|
||||
href={`/${prev.slug}`}
|
||||
passHref
|
||||
className='text-sm cursor-pointer justify-start items-center flex hover:underline duration-300'>
|
||||
|
||||
<i className='mr-1 fas fa-angle-double-left' />{prev.title}
|
||||
|
||||
</SmartLink>}
|
||||
{next && <SmartLink
|
||||
href={`/${next.slug}`}
|
||||
passHref
|
||||
className='text-sm cursor-pointer justify-end items-center flex hover:underline duration-300'>
|
||||
{next.title}
|
||||
<i className='ml-1 my-1 fas fa-angle-double-right' />
|
||||
|
||||
</SmartLink>}
|
||||
</section>
|
||||
)
|
||||
}
|
65
mhhung/components/ArticleInfo.js
Normal file
65
mhhung/components/ArticleInfo.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import SmartLink from '@/components/SmartLink'
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import CONFIG from '../config'
|
||||
import { siteConfig } from '@/lib/config'
|
||||
import { formatDateFmt } from '@/lib/utils/formatDate'
|
||||
import NotionIcon from '@/components/NotionIcon'
|
||||
|
||||
/**
|
||||
* 文章描述
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
export default function ArticleInfo(props) {
|
||||
const { post } = props
|
||||
|
||||
const { locale } = useGlobal()
|
||||
|
||||
return (
|
||||
<section className='mt-2 text-gray-600 dark:text-gray-400 leading-8'>
|
||||
<h2 className='blog-item-title mb-5 font-bold text-black text-xl md:text-2xl no-underline'>
|
||||
{siteConfig('POST_TITLE_ICON') && <NotionIcon icon={post?.pageIcon} />}
|
||||
{post?.title}
|
||||
</h2>
|
||||
|
||||
<div className='flex flex-wrap text-[var(--primary-color)] dark:text-gray-300'>
|
||||
{post?.type !== 'Page' && (
|
||||
<header className='text-md text-[var(--primary-color)] dark:text-gray-300 flex-wrap flex items-center leading-6'>
|
||||
<div className='space-x-2'>
|
||||
<span className='text-sm'>
|
||||
发布于
|
||||
<SmartLink
|
||||
className='p-1 hover:text-red-400 transition-all duration-200'
|
||||
href={`/archive#${formatDateFmt(post?.publishDate, 'yyyy-MM')}`}>
|
||||
{post.date?.start_date || post.createdTime}
|
||||
</SmartLink>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='text-sm'>
|
||||
{/* {post.category && (
|
||||
<SmartLink href={`/category/${post.category}`} className='p-1'>
|
||||
{' '}
|
||||
<span className='hover:text-red-400 transition-all duration-200'>
|
||||
<i className='fa-regular fa-folder mr-0.5' />
|
||||
{post.category}
|
||||
</span>
|
||||
</SmartLink>
|
||||
)} */}
|
||||
{post?.tags &&
|
||||
post?.tags?.length > 0 &&
|
||||
post?.tags.map(t => (
|
||||
<SmartLink
|
||||
key={t}
|
||||
href={`/tag/${t}`}
|
||||
className=' hover:text-red-400 transition-all duration-200'>
|
||||
<span> #{t}</span>
|
||||
</SmartLink>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
52
mhhung/components/ArticleLock.js
Normal file
52
mhhung/components/ArticleLock.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* 加密文章校验组件
|
||||
* @param {password, validPassword} props
|
||||
* @param password 正确的密码
|
||||
* @param validPassword(bool) 回调函数,校验正确回调入参为true
|
||||
* @returns
|
||||
*/
|
||||
export default function ArticleLock (props) {
|
||||
const { validPassword } = props
|
||||
const { locale } = useGlobal()
|
||||
|
||||
const submitPassword = () => {
|
||||
const p = document.getElementById('password')
|
||||
if (!validPassword(p?.value)) {
|
||||
const tips = document.getElementById('tips')
|
||||
if (tips) {
|
||||
tips.innerHTML = ''
|
||||
tips.innerHTML = `<div class='text-red-500 animate__shakeX animate__animated'>${locale.COMMON.PASSWORD_ERROR}</div>`
|
||||
}
|
||||
}
|
||||
}
|
||||
const passwordInputRef = useRef(null)
|
||||
useEffect(() => {
|
||||
// 选中密码输入框并将其聚焦
|
||||
passwordInputRef.current.focus()
|
||||
}, [])
|
||||
|
||||
return <div id='container' className='w-full flex justify-center items-center h-96 '>
|
||||
<div className='text-center space-y-3'>
|
||||
<div className='font-bold'>{locale.COMMON.ARTICLE_LOCK_TIPS}</div>
|
||||
<div className='flex mx-4'>
|
||||
<input id="password" type='password'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
submitPassword()
|
||||
}
|
||||
}}
|
||||
ref={passwordInputRef} // 绑定ref到passwordInputRef变量
|
||||
className='outline-none w-full text-sm pl-5 rounded-l transition focus:shadow-lg font-light leading-10 text-black dark:bg-gray-500 bg-gray-50'
|
||||
></input>
|
||||
<div onClick={submitPassword} className="px-3 whitespace-nowrap cursor-pointer items-center justify-center py-2 rounded-r duration-300 bg-gray-300" >
|
||||
<i className={'duration-200 cursor-pointer fas fa-key dark:text-black'} > {locale.COMMON.SUBMIT}</i>
|
||||
</div>
|
||||
</div>
|
||||
<div id='tips'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
36
mhhung/components/BlogArchiveItem.js
Normal file
36
mhhung/components/BlogArchiveItem.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import SmartLink from '@/components/SmartLink'
|
||||
|
||||
/**
|
||||
* 归档分组文章
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
export default function BlogArchiveItem({ archiveTitle, archivePosts }) {
|
||||
return (
|
||||
<div key={archiveTitle} className='pb-16'>
|
||||
<div id={archiveTitle} className='text-[#111827] opacity-30 pb-2 text-3xl dark:text-gray-300'>
|
||||
{archiveTitle}
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{archivePosts.map(post => {
|
||||
return (
|
||||
<li
|
||||
key={post.id}
|
||||
className='p-1 pl-0 text-base items-center mb-3'>
|
||||
<div id={post?.publishDay} className='flex justify-between'>
|
||||
<SmartLink
|
||||
href={post?.href}
|
||||
passHref
|
||||
className='dark:text-gray-400 dark:hover:text-gray-300 overflow-x-hidden cursor-pointer text-[#111827] font-bold'>
|
||||
{post.title}
|
||||
</SmartLink>
|
||||
<span className='text-gray-400'>{post.date?.start_date}</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
101
mhhung/components/BlogItem.js
Normal file
101
mhhung/components/BlogItem.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import LazyImage from '@/components/LazyImage'
|
||||
import NotionIcon from '@/components/NotionIcon'
|
||||
import NotionPage from '@/components/NotionPage'
|
||||
import TwikooCommentCount from '@/components/TwikooCommentCount'
|
||||
import { siteConfig } from '@/lib/config'
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import { formatDateFmt } from '@/lib/utils/formatDate'
|
||||
import SmartLink from '@/components/SmartLink'
|
||||
import CONFIG from '../config'
|
||||
|
||||
export const BlogItem = props => {
|
||||
const { post } = props
|
||||
const { NOTION_CONFIG } = useGlobal()
|
||||
const showPageCover = siteConfig('TYPOGRAPHY_POST_COVER_ENABLE', false, CONFIG)
|
||||
const showPreview =
|
||||
siteConfig('POST_LIST_PREVIEW', false, NOTION_CONFIG) && post.blockMap
|
||||
return (
|
||||
<div key={post.id} className='h-42 mt-6 mb-10'>
|
||||
{/* 文章标题 */}
|
||||
|
||||
<div className='flex'>
|
||||
<div className='article-cover h-full'>
|
||||
{/* 图片封面 */}
|
||||
{showPageCover && (
|
||||
<div className='overflow-hidden mr-2 w-56 h-full'>
|
||||
<SmartLink href={post.href} passHref legacyBehavior>
|
||||
<LazyImage
|
||||
src={post?.pageCoverThumbnail}
|
||||
className='w-56 h-full object-cover object-center group-hover:scale-110 duration-500'
|
||||
/>
|
||||
</SmartLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<article className='article-info'>
|
||||
<h2 className='mb-2'>
|
||||
<SmartLink
|
||||
href={post.href}
|
||||
className='text-xl underline decoration-2 font-bold text-[var(--primary-color)] dark:text-white dark:hover:bg-white dark:hover:text-[var(--primary-color)] duration-200 transition-all rounded-sm'>
|
||||
{siteConfig('POST_TITLE_ICON') && (
|
||||
<NotionIcon icon={post.pageIcon} />
|
||||
)}
|
||||
{post.title}
|
||||
</SmartLink>
|
||||
</h2>
|
||||
|
||||
{/* 文章信息 */}
|
||||
<header className='text-md text-[var(--primary-color)] dark:text-gray-300 flex-wrap flex items-center leading-6'>
|
||||
<div className='space-x-2'>
|
||||
<span className='text-sm'>
|
||||
发布于
|
||||
<SmartLink
|
||||
className='p-1 hover:text-red-400 transition-all duration-200'
|
||||
href={`/archive#${formatDateFmt(post?.publishDate, 'yyyy-MM')}`}>
|
||||
{post.date?.start_date || post.createdTime}
|
||||
</SmartLink>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='text-sm'>
|
||||
{/* {post.category && (
|
||||
<SmartLink href={`/category/${post.category}`} className='p-1'>
|
||||
{' '}
|
||||
<span className='hover:text-red-400 transition-all duration-200'>
|
||||
<i className='fa-regular fa-folder mr-0.5' />
|
||||
{post.category}
|
||||
</span>
|
||||
</SmartLink>
|
||||
)} */}
|
||||
{post?.tags &&
|
||||
post?.tags?.length > 0 &&
|
||||
post?.tags.map(t => (
|
||||
<SmartLink
|
||||
key={t}
|
||||
href={`/tag/${t}`}
|
||||
className=' hover:text-red-400 transition-all duration-200'>
|
||||
<span> #{t}</span>
|
||||
</SmartLink>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className='text-[var(--primary-color)] dark:text-gray-300 line-clamp-4 overflow-hidden text-ellipsis relative leading-[1.7]'>
|
||||
{!showPreview && (
|
||||
<>
|
||||
{post.summary}
|
||||
</>
|
||||
)}
|
||||
{showPreview && post?.blockMap && (
|
||||
<div className='line-clamp-4 overflow-hidden'>
|
||||
<NotionPage post={post} />
|
||||
<hr className='border-dashed py-4' />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
74
mhhung/components/BlogListPage.js
Normal file
74
mhhung/components/BlogListPage.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { AdSlot } from '@/components/GoogleAdsense'
|
||||
import { siteConfig } from '@/lib/config'
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import SmartLink from '@/components/SmartLink'
|
||||
import { useRouter } from 'next/router'
|
||||
import CONFIG from '../config'
|
||||
import { BlogItem } from './BlogItem'
|
||||
|
||||
/**
|
||||
* 博客列表
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
export default function BlogListPage(props) {
|
||||
const { page = 1, posts, postCount } = props
|
||||
const router = useRouter()
|
||||
const { NOTION_CONFIG } = useGlobal()
|
||||
const POSTS_PER_PAGE = siteConfig('POSTS_PER_PAGE', null, NOTION_CONFIG)
|
||||
const totalPage = Math.ceil(postCount / POSTS_PER_PAGE)
|
||||
const currentPage = +page
|
||||
|
||||
// 博客列表嵌入广告
|
||||
const TYPOGRAPHY_POST_AD_ENABLE = siteConfig(
|
||||
'TYPOGRAPHY_POST_AD_ENABLE',
|
||||
false,
|
||||
CONFIG
|
||||
)
|
||||
|
||||
const showPrev = currentPage > 1
|
||||
const showNext = page < totalPage
|
||||
const pagePrefix = router.asPath
|
||||
.split('?')[0]
|
||||
.replace(/\/page\/[1-9]\d*/, '')
|
||||
.replace(/\/$/, '')
|
||||
.replace('.html', '')
|
||||
|
||||
return (
|
||||
<div className='w-full md:pr-8 mb-12 px-5'>
|
||||
<div id='posts-wrapper'>
|
||||
{posts?.map((p, index) => (
|
||||
<div key={p.id}>
|
||||
{TYPOGRAPHY_POST_AD_ENABLE && (index + 1) % 3 === 0 && (
|
||||
<AdSlot type='in-article' />
|
||||
)}
|
||||
{TYPOGRAPHY_POST_AD_ENABLE && index + 1 === 4 && <AdSlot type='flow' />}
|
||||
<BlogItem post={p} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='flex justify-between text-xs mt-1'>
|
||||
<SmartLink
|
||||
href={{
|
||||
pathname:
|
||||
currentPage - 1 === 1
|
||||
? `${pagePrefix}/`
|
||||
: `${pagePrefix}/page/${currentPage - 1}`,
|
||||
query: router.query.s ? { s: router.query.s } : {}
|
||||
}}
|
||||
className={`${showPrev ? 'text-blue-600 border-b border-blue-400 visible ' : ' invisible bg-gray pointer-events-none '} no-underline pb-1 px-3`}>
|
||||
NEWER POSTS <i className='fa-solid fa-arrow-left'></i>
|
||||
</SmartLink>
|
||||
<SmartLink
|
||||
href={{
|
||||
pathname: `${pagePrefix}/page/${currentPage + 1}`,
|
||||
query: router.query.s ? { s: router.query.s } : {}
|
||||
}}
|
||||
className={`${showNext ? 'text-blue-600 border-b border-blue-400 visible' : ' invisible bg-gray pointer-events-none '} no-underline pb-1 px-3`}>
|
||||
OLDER POSTS <i className='fa-solid fa-arrow-right'></i>
|
||||
</SmartLink>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
70
mhhung/components/BlogListScroll.js
Normal file
70
mhhung/components/BlogListScroll.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { siteConfig } from '@/lib/config'
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import throttle from 'lodash.throttle'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { BlogItem } from './BlogItem'
|
||||
|
||||
/**
|
||||
* 滚动博客列表
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
export default function BlogListScroll(props) {
|
||||
const { posts } = props
|
||||
const { locale, NOTION_CONFIG } = useGlobal()
|
||||
const [page, updatePage] = useState(1)
|
||||
const POSTS_PER_PAGE = siteConfig('POSTS_PER_PAGE', null, NOTION_CONFIG)
|
||||
let hasMore = false
|
||||
const postsToShow = posts
|
||||
? Object.assign(posts).slice(0, POSTS_PER_PAGE * page)
|
||||
: []
|
||||
|
||||
if (posts) {
|
||||
const totalCount = posts.length
|
||||
hasMore = page * POSTS_PER_PAGE < totalCount
|
||||
}
|
||||
const handleGetMore = () => {
|
||||
if (!hasMore) return
|
||||
updatePage(page + 1)
|
||||
}
|
||||
|
||||
const targetRef = useRef(null)
|
||||
|
||||
// 监听滚动自动分页加载
|
||||
const scrollTrigger = useCallback(
|
||||
throttle(() => {
|
||||
const scrollS = window.scrollY + window.outerHeight
|
||||
const clientHeight = targetRef
|
||||
? targetRef.current
|
||||
? targetRef.current.clientHeight
|
||||
: 0
|
||||
: 0
|
||||
if (scrollS > clientHeight + 100) {
|
||||
handleGetMore()
|
||||
}
|
||||
}, 500)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('scroll', scrollTrigger)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', scrollTrigger)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div id='posts-wrapper' className='w-full md:pr-8 mb-12' ref={targetRef}>
|
||||
{postsToShow.map(p => (
|
||||
<BlogItem key={p.id} post={p} />
|
||||
))}
|
||||
|
||||
<div
|
||||
onClick={handleGetMore}
|
||||
className='w-full my-4 py-4 text-center cursor-pointer '>
|
||||
{' '}
|
||||
{hasMore ? locale.COMMON.MORE : `${locale.COMMON.NO_MORE} 😰`}{' '}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
29
mhhung/components/BlogPostBar.js
Normal file
29
mhhung/components/BlogPostBar.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useGlobal } from '@/lib/global'
|
||||
|
||||
/**
|
||||
* 文章列表上方嵌入
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
export default function BlogPostBar(props) {
|
||||
const { tag, category } = props
|
||||
const { locale } = useGlobal()
|
||||
|
||||
if (tag) {
|
||||
return (
|
||||
<div className='flex items-center text-xl py-2'>
|
||||
<i className='mr-2 fas fa-tag' />
|
||||
{locale.COMMON.TAGS}: {tag}
|
||||
</div>
|
||||
)
|
||||
} else if (category) {
|
||||
return (
|
||||
<div className='flex items-center text-xl py-2'>
|
||||
<i className='mr-2 fas fa-th' />
|
||||
{locale.COMMON.CATEGORY}: {category}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return <></>
|
||||
}
|
||||
}
|
136
mhhung/components/Catalog.js
Normal file
136
mhhung/components/Catalog.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import throttle from 'lodash.throttle'
|
||||
import { uuidToId } from 'notion-utils'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* 目录导航组件
|
||||
* @param toc
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const Catalog = ({ post }) => {
|
||||
const { locale } = useGlobal()
|
||||
// 目录自动滚动
|
||||
const tRef = useRef(null)
|
||||
// 同步选中目录事件
|
||||
const [activeSection, setActiveSection] = useState(null)
|
||||
|
||||
// 监听滚动事件
|
||||
useEffect(() => {
|
||||
// 如果没有文章或目录,不执行任何操作
|
||||
if (!post || !post?.toc || post?.toc?.length < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const throttleMs = 100 // 降低节流时间提高响应速度
|
||||
|
||||
const actionSectionScrollSpy = throttle(() => {
|
||||
const sections = document.getElementsByClassName('notion-h')
|
||||
if (!sections || sections.length === 0) return
|
||||
|
||||
let prevBBox = null
|
||||
let currentSectionId = null
|
||||
|
||||
// 先检查当前视口中的所有标题
|
||||
for (let i = 0; i < sections.length; ++i) {
|
||||
const section = sections[i]
|
||||
if (!section || !(section instanceof Element)) continue
|
||||
|
||||
const bbox = section.getBoundingClientRect()
|
||||
const offset = 100 // 固定偏移量,避免计算不稳定
|
||||
|
||||
// 如果标题在视口上方或接近顶部,认为是当前标题
|
||||
if (bbox.top - offset < 0) {
|
||||
currentSectionId = section.getAttribute('data-id')
|
||||
prevBBox = bbox
|
||||
} else {
|
||||
// 找到第一个在视口下方的标题就停止
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到任何标题在视口上方,使用第一个标题
|
||||
if (!currentSectionId && sections.length > 0) {
|
||||
currentSectionId = sections[0].getAttribute('data-id')
|
||||
}
|
||||
|
||||
// 只有当 ID 变化时才更新状态,减少不必要的渲染
|
||||
if (currentSectionId !== activeSection) {
|
||||
setActiveSection(currentSectionId)
|
||||
|
||||
// 查找目录中对应的索引并滚动
|
||||
const index = post?.toc?.findIndex(
|
||||
obj => uuidToId(obj.id) === currentSectionId
|
||||
)
|
||||
|
||||
if (index !== -1 && tRef?.current) {
|
||||
tRef.current.scrollTo({ top: 28 * index, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
}, throttleMs)
|
||||
|
||||
const content = document.querySelector('#container-inner')
|
||||
if (!content) return // 防止 content 不存在
|
||||
|
||||
// 添加滚动和内容变化的监听
|
||||
content.addEventListener('scroll', actionSectionScrollSpy)
|
||||
|
||||
// 初始执行一次
|
||||
setTimeout(() => {
|
||||
actionSectionScrollSpy()
|
||||
}, 300) // 延迟执行确保 DOM 已完全加载
|
||||
|
||||
return () => {
|
||||
content?.removeEventListener('scroll', actionSectionScrollSpy)
|
||||
}
|
||||
}, [post])
|
||||
|
||||
// 无目录就直接返回空
|
||||
if (!post || !post?.toc || post?.toc?.length < 1) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='px-3 '>
|
||||
<div className='dark:text-white mb-2'>
|
||||
<i className='mr-1 fas fa-stream' />
|
||||
{locale.COMMON.TABLE_OF_CONTENTS}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='overflow-y-auto overscroll-none max-h-36 lg:max-h-96 scroll-hidden'
|
||||
ref={tRef}>
|
||||
<nav className='h-full text-black group'>
|
||||
{post?.toc?.map(tocItem => {
|
||||
const id = uuidToId(tocItem.id)
|
||||
return (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={`${activeSection === id
|
||||
? 'dark:border-white border-red-700 text-red-700 font-bold'
|
||||
: 'text-[var(--primary-color)] dark:text-gray-500 filter blur-[1px] opacity-50 group-hover:filter-none group-hover:blur-0 group-hover:opacity-100'}
|
||||
hover:font-semibold hover:text-red-600 hover:filter-none hover:blur-0 hover:opacity-100
|
||||
border-l pl-4 block border-lduration-300 transform
|
||||
dark:hover:text-red-400 dark:border-gray-600
|
||||
notion-table-of-contents-item-indent-level-${tocItem.indentLevel} catalog-item
|
||||
transition-all duration-300 ease-in-out`}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
marginLeft: tocItem.indentLevel * 16
|
||||
}}
|
||||
className={`truncate ${activeSection === id ? 'font-bold text-red-600 dark:text-white underline' : ''}`}>
|
||||
{tocItem.text}
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Catalog
|
35
mhhung/components/ExampleRecentComments.js
Normal file
35
mhhung/components/ExampleRecentComments.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import SmartLink from '@/components/SmartLink'
|
||||
import { RecentComments } from '@waline/client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { siteConfig } from '@/lib/config'
|
||||
|
||||
/**
|
||||
* @see https://waline.js.org/guide/get-started.html
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const ExampleRecentComments = (props) => {
|
||||
const [comments, updateComments] = useState([])
|
||||
const [onLoading, changeLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
RecentComments({
|
||||
serverURL: siteConfig('COMMENT_WALINE_SERVER_URL'),
|
||||
count: 5
|
||||
}).then(({ comments }) => {
|
||||
changeLoading(false)
|
||||
updateComments(comments)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return <>
|
||||
{onLoading && <div>Loading...<i className='ml-2 fas fa-spinner animate-spin' /></div>}
|
||||
{!onLoading && comments && comments.length === 0 && <div>No Comments</div>}
|
||||
{!onLoading && comments && comments.length > 0 && comments.map((comment) => <div key={comment.objectId} className='pb-2'>
|
||||
<div className='dark:text-gray-300 text-gray-600 text-xs waline-recent-content wl-content' dangerouslySetInnerHTML={{ __html: comment.comment }} />
|
||||
<div className='dark:text-gray-400 text-gray-400 text-sm text-right cursor-pointer hover:text-red-500 hover:underline pt-1'><SmartLink href={{ pathname: comment.url, hash: comment.objectId, query: { target: 'comment' } }}>--{comment.nick}</SmartLink></div>
|
||||
</div>)}
|
||||
|
||||
</>
|
||||
}
|
||||
|
||||
export default ExampleRecentComments
|
29
mhhung/components/Footer.js
Normal file
29
mhhung/components/Footer.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { BeiAnGongAn } from '@/components/BeiAnGongAn'
|
||||
import DarkModeButton from '@/components/DarkModeButton'
|
||||
import { siteConfig } from '@/lib/config'
|
||||
|
||||
/**
|
||||
* 页脚
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
export default function Footer(props) {
|
||||
const d = new Date()
|
||||
const currentYear = d.getFullYear()
|
||||
const since = siteConfig('SINCE')
|
||||
const copyrightDate =
|
||||
parseInt(since) < currentYear ? since + '-' + currentYear : currentYear
|
||||
|
||||
return (
|
||||
<footer>
|
||||
<DarkModeButton className='pt-4' />
|
||||
|
||||
<div className='font-bold text-[var(--primary-color)] dark:text-white py-6 text-sm flex flex-col gap-2 items-center'>
|
||||
<div>
|
||||
©{`${copyrightDate}`} {siteConfig('AUTHOR')}.
|
||||
</div>
|
||||
<div>All rights reserved.</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
35
mhhung/components/JumpToTopButton.js
Normal file
35
mhhung/components/JumpToTopButton.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* 跳转到网页顶部
|
||||
* 当屏幕下滑500像素后会出现该控件
|
||||
* @param targetRef 关联高度的目标html标签
|
||||
* @param showPercent 是否显示百分比
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const JumpToTopButton = () => {
|
||||
const { locale } = useGlobal()
|
||||
const [show, switchShow] = useState(false)
|
||||
const scrollListener = () => {
|
||||
const scrollY = window.pageYOffset
|
||||
const shouldShow = scrollY > 200
|
||||
if (shouldShow !== show) {
|
||||
switchShow(shouldShow)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('scroll', scrollListener)
|
||||
return () => document.removeEventListener('scroll', scrollListener)
|
||||
}, [show])
|
||||
|
||||
return <div title={locale.POST.TOP}
|
||||
className={(show ? ' opacity-100 ' : 'invisible opacity-0') + ' transition-all duration-300 flex items-center justify-center cursor-pointer bg-black h-10 w-10 bg-opacity-40 rounded-sm'}
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
><i className='fas fa-angle-up text-white ' />
|
||||
</div>
|
||||
}
|
||||
|
||||
export default JumpToTopButton
|
92
mhhung/components/MenuItemCollapse.js
Normal file
92
mhhung/components/MenuItemCollapse.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import Collapse from '@/components/Collapse'
|
||||
import SmartLink from '@/components/SmartLink'
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* 折叠菜单
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
export const MenuItemCollapse = props => {
|
||||
const { link } = props
|
||||
const [show, changeShow] = useState(false)
|
||||
const hasSubMenu = link?.subMenus?.length > 0
|
||||
|
||||
const [isOpen, changeIsOpen] = useState(false)
|
||||
|
||||
const toggleShow = () => {
|
||||
changeShow(!show)
|
||||
}
|
||||
|
||||
const toggleOpenSubMenu = () => {
|
||||
changeIsOpen(!isOpen)
|
||||
}
|
||||
|
||||
if (!link || !link.show) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='w-full px-8 py-3 text-left border-b dark:bg-hexo-black-gray dark:border-black'
|
||||
onClick={toggleShow}>
|
||||
{!hasSubMenu && (
|
||||
<SmartLink
|
||||
href={link?.href}
|
||||
target={link?.target}
|
||||
className='items-center flex justify-between pl-2 pr-4 dark:text-gray-200 no-underline tracking-widest pb-1'>
|
||||
<span className='text-blue-600 dark:text-blue-300 hover:text-red-400 transition-all items-center duration-200'>
|
||||
{link?.icon && (
|
||||
<span className='mr-2'>
|
||||
<i className={link.icon} />
|
||||
</span>
|
||||
)}
|
||||
{link?.name}
|
||||
</span>
|
||||
</SmartLink>
|
||||
)}
|
||||
{hasSubMenu && (
|
||||
<div
|
||||
onClick={hasSubMenu ? toggleOpenSubMenu : null}
|
||||
className='items-center flex justify-between pl-2 pr-4 cursor-pointer dark:text-gray-200 no-underline tracking-widest pb-1'>
|
||||
<span className='text-blue-600 dark:text-blue-300 hover:text-red-400 transition-all items-center duration-200'>
|
||||
{link?.icon && (
|
||||
<span className='mr-2'>
|
||||
<i className={link.icon} />
|
||||
</span>
|
||||
)}
|
||||
{link?.name}
|
||||
</span>
|
||||
<i
|
||||
className={`px-2 fa fa-plus transition-all duration-200 ${isOpen && 'rotate-45'} text-gray-400`}></i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 折叠子菜单 */}
|
||||
{hasSubMenu && (
|
||||
<Collapse isOpen={isOpen} onHeightChange={props.onHeightChange}>
|
||||
{link.subMenus.map((sLink, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className='dark:bg-black text-left px-10 justify-start text-blue-600 dark:text-blue-300 bg-gray-50 hover:bg-gray-50 dark:hover:bg-gray-900 tracking-widest transition-all duration-200 border-b dark:border-gray-800 py-3 pr-6'>
|
||||
<SmartLink href={sLink.href} target={link?.target}>
|
||||
<span className='ml-4 text-sm'>
|
||||
{sLink?.icon && (
|
||||
<span className='mr-2 w-4'>
|
||||
<i className={sLink.icon} />
|
||||
</span>
|
||||
)}
|
||||
{sLink.title}
|
||||
</span>
|
||||
</SmartLink>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Collapse>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
78
mhhung/components/MenuItemDrop.js
Normal file
78
mhhung/components/MenuItemDrop.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import SmartLink from '@/components/SmartLink'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
|
||||
export const MenuItemDrop = ({ link }) => {
|
||||
const hasSubMenu = link?.subMenus?.length > 0
|
||||
const [show, changeShow] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
|
||||
|
||||
if (!link || !link.show) {
|
||||
return null
|
||||
}
|
||||
const selected = router.pathname === link.href || router.asPath === link.href
|
||||
|
||||
|
||||
return (
|
||||
<div className='menu-item'>
|
||||
{!hasSubMenu && (
|
||||
<SmartLink
|
||||
href={link?.href}
|
||||
target={link?.target}
|
||||
className='dark:hover:text-[var(--primary-color)] dark:hover:bg-white menu-link underline decoration-2 hover:no-underline hover:bg-[#2E405B] hover:text-white text-[var(--primary-color)] dark:text-gray-200 tracking-widest pb-1 font-bold'>
|
||||
{link?.name}
|
||||
</SmartLink>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
{hasSubMenu && (
|
||||
<>
|
||||
<div
|
||||
onMouseOver={() => changeShow(true)}
|
||||
onMouseOut={() => changeShow(false)}
|
||||
className={
|
||||
'relative ' +
|
||||
(selected
|
||||
? 'bg-green-600 text-white hover:text-white'
|
||||
: 'hover:text-green-600')
|
||||
}>
|
||||
<div>
|
||||
<span className='dark:hover:text-[var(--primary-color)] dark:hover:bg-white menu-link underline decoration-2 hover:no-underline hover:bg-[#2E405B] hover:text-white text-[var(--primary-color)] dark:text-gray-200 tracking-widest pb-1 font-bold'>
|
||||
{link?.icon && <i className={link?.icon} />} {link?.name}
|
||||
</span>
|
||||
{hasSubMenu && (
|
||||
<i
|
||||
className={`px-2 fas fa-chevron-right duration-500 transition-all ${show ? ' rotate-180' : ''}`}></i>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 子菜單 */}
|
||||
<ul
|
||||
className={`${show ? 'visible opacity-100' : 'invisible opacity-0'} absolute glassmorphism md:left-28 md:top-0 top-6 w-full border-gray-100 transition-all duration-300 z-20 block`}>
|
||||
{link?.subMenus?.map((sLink, index) => {
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className='dark:hover:bg-gray-900 tracking-widest transition-all duration-200 dark:border-gray-800 pb-3'>
|
||||
<SmartLink href={sLink.href} target={link?.target}>
|
||||
<span className='dark:hover:text-[var(--primary-color)] dark:hover:bg-white menu-link underline decoration-2 hover:no-underline hover:bg-[#2E405B] hover:text-white text-[var(--primary-color)] dark:text-gray-200 tracking-widest pb-1 font-bold'>
|
||||
{link?.icon && <i className={sLink?.icon}> </i>}
|
||||
{sLink.title}
|
||||
</span>
|
||||
</SmartLink>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
83
mhhung/components/MenuList.js
Normal file
83
mhhung/components/MenuList.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import Collapse from '@/components/Collapse'
|
||||
import { siteConfig } from '@/lib/config'
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import CONFIG from '../config'
|
||||
import { MenuItemCollapse } from './MenuItemCollapse'
|
||||
import { MenuItemDrop } from './MenuItemDrop'
|
||||
|
||||
/**
|
||||
* 菜单导航
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
export const MenuList = ({ customNav, customMenu }) => {
|
||||
const { locale } = useGlobal()
|
||||
const [isOpen, changeIsOpen] = useState(false)
|
||||
const toggleIsOpen = () => {
|
||||
changeIsOpen(!isOpen)
|
||||
}
|
||||
const closeMenu = e => {
|
||||
changeIsOpen(false)
|
||||
}
|
||||
const router = useRouter()
|
||||
const collapseRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
router.events.on('routeChangeStart', closeMenu)
|
||||
})
|
||||
|
||||
let links = [
|
||||
{
|
||||
icon: 'fas fa-archive',
|
||||
name: locale.NAV.ARCHIVE,
|
||||
href: '/archive',
|
||||
show: siteConfig('TYPOGRAPHY_MENU_ARCHIVE', null, CONFIG)
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-folder',
|
||||
name: locale.COMMON.CATEGORY,
|
||||
href: '/category',
|
||||
show: siteConfig('TYPOGRAPHY_MENU_CATEGORY', null, CONFIG)
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-tag',
|
||||
name: locale.COMMON.TAGS,
|
||||
href: '/tag',
|
||||
show: siteConfig('TYPOGRAPHY_MENU_TAG', null, CONFIG)
|
||||
}
|
||||
]
|
||||
|
||||
if (customNav) {
|
||||
links = links.concat(customNav)
|
||||
}
|
||||
|
||||
// 如果 开启自定义菜单,则覆盖 Page 生成的菜单
|
||||
if (siteConfig('CUSTOM_MENU')) {
|
||||
links = customMenu
|
||||
}
|
||||
|
||||
if (!links || links.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 大屏模式菜单 - 垂直排列 */}
|
||||
<div id='nav-menu-pc' className='hidden md:flex md:flex-col md:gap-2'>
|
||||
{links?.map((link, index) => (
|
||||
<MenuItemDrop key={index} link={link} />
|
||||
))}
|
||||
</div>
|
||||
{/* 移动端小屏菜单 - 水平排列 */}
|
||||
<div
|
||||
id='nav-menu-mobile'
|
||||
className='flex md:hidden my-auto justify-center space-x-4'>
|
||||
{links?.map((link, index) => (
|
||||
<MenuItemDrop key={index} link={link} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
37
mhhung/components/NavBar.js
Normal file
37
mhhung/components/NavBar.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { siteConfig } from '@/lib/config'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { useSimpleGlobal } from '..'
|
||||
import { MenuList } from './MenuList'
|
||||
import SocialButton from './SocialButton'
|
||||
import SmartLink from '@/components/SmartLink'
|
||||
|
||||
/**
|
||||
* 菜单导航
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
export default function NavBar(props) {
|
||||
return (
|
||||
<div className='flex flex-col justify-between md:mt-20 md:h-[70vh]'>
|
||||
<header className='w-fit self-center md:self-start md:pb-8 md:border-l-2 dark:md:border-white dark:text-white md:border-[var(--primary-color)] text-[var(--primary-color)] md:[writing-mode:vertical-lr] px-4 hover:bg-[var(--primary-color)] dark:hover:bg-white hover:text-white dark:hover:text-[var(--primary-color)] ease-in-out duration-700 md:hover:pt-4 md:hover:pb-4 mb-2'>
|
||||
<SmartLink href='/'>
|
||||
<div className='flex flex-col-reverse md:flex-col items-center md:items-start'>
|
||||
<div className='font-bold text-4xl text-center' id='blog-name'>
|
||||
{siteConfig('TYPOGRAPHY_BLOG_NAME')}
|
||||
</div>
|
||||
<div className='font-bold text-xl text-center' id='blog-name-en'>
|
||||
{siteConfig('TYPOGRAPHY_BLOG_NAME_EN')}
|
||||
</div>
|
||||
</div>
|
||||
</SmartLink>
|
||||
</header>
|
||||
<nav className='md:pt-0 z-20 flex-shrink-0'>
|
||||
<div id='nav-bar-inner' className='text-sm md:text-md'>
|
||||
<MenuList {...props} />
|
||||
</div>
|
||||
<SocialButton />
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
32
mhhung/components/RecommendPosts.js
Normal file
32
mhhung/components/RecommendPosts.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import SmartLink from '@/components/SmartLink'
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import CONFIG from '../config'
|
||||
import { siteConfig } from '@/lib/config'
|
||||
|
||||
/**
|
||||
* 展示文章推荐
|
||||
*/
|
||||
const RecommendPosts = ({ recommendPosts }) => {
|
||||
const { locale } = useGlobal()
|
||||
if (!siteConfig('TYPOGRAPHY_ARTICLE_RECOMMEND_POSTS', null, CONFIG) || !recommendPosts || recommendPosts.length < 1) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-2 border pl-4 py-2 my-4 dark:text-gray-300 ">
|
||||
<div className="mb-2 font-bold text-lg">{locale.COMMON.RELATE_POSTS} :</div>
|
||||
<ul className="font-light text-sm">
|
||||
{recommendPosts.map(post => (
|
||||
<li className="py-1" key={post.id}>
|
||||
<SmartLink href={`/${post.slug}`} className="cursor-pointer hover:underline">
|
||||
|
||||
{post.title}
|
||||
|
||||
</SmartLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default RecommendPosts
|
115
mhhung/components/SocialButton.js
Normal file
115
mhhung/components/SocialButton.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { siteConfig } from '@/lib/config'
|
||||
|
||||
/**
|
||||
* 社交联系方式按钮组
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const SocialButton = () => {
|
||||
return (
|
||||
<div className='justify-center w-full md:justify-start md:w-52 flex-wrap flex my-2'>
|
||||
<div className='space-x-5 text-xl text-gray-600 dark:text-gray-400 text-center'>
|
||||
{siteConfig('CONTACT_GITHUB') && (
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
title={'github'}
|
||||
href={siteConfig('CONTACT_GITHUB')}>
|
||||
<i className='fab fa-github transform hover:scale-125 duration-150' />
|
||||
</a>
|
||||
)}
|
||||
{siteConfig('CONTACT_TWITTER') && (
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
title={'twitter'}
|
||||
href={siteConfig('CONTACT_TWITTER')}>
|
||||
<i className='fab fa-twitter transform hover:scale-125 duration-150' />
|
||||
</a>
|
||||
)}
|
||||
{siteConfig('CONTACT_TELEGRAM') && (
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
href={siteConfig('CONTACT_TELEGRAM')}
|
||||
title={'telegram'}>
|
||||
<i className='fab fa-telegram transform hover:scale-125 duration-150' />
|
||||
</a>
|
||||
)}
|
||||
{siteConfig('CONTACT_LINKEDIN') && (
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
href={siteConfig('CONTACT_LINKEDIN')}
|
||||
title={'linkedIn'}>
|
||||
<i className='fab fa-linkedin transform hover:scale-125 duration-150' />
|
||||
</a>
|
||||
)}
|
||||
{siteConfig('CONTACT_WEIBO') && (
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
title={'weibo'}
|
||||
href={siteConfig('CONTACT_WEIBO')}>
|
||||
<i className='fab fa-weibo transform hover:scale-125 duration-150' />
|
||||
</a>
|
||||
)}
|
||||
{siteConfig('CONTACT_INSTAGRAM') && (
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
title={'instagram'}
|
||||
href={siteConfig('CONTACT_INSTAGRAM')}>
|
||||
<i className='fab fa-instagram transform hover:scale-125 duration-150' />
|
||||
</a>
|
||||
)}
|
||||
{siteConfig('CONTACT_EMAIL') && (
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
title={'email'}
|
||||
href={`mailto:${siteConfig('CONTACT_EMAIL')}`}>
|
||||
<i className='fas fa-envelope transform hover:scale-125 duration-150' />
|
||||
</a>
|
||||
)}
|
||||
{JSON.parse(siteConfig('ENABLE_RSS')) && (
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
title={'RSS'}
|
||||
href={'/rss/feed.xml'}>
|
||||
<i className='fas fa-rss transform hover:scale-125 duration-150' />
|
||||
</a>
|
||||
)}
|
||||
{siteConfig('CONTACT_BILIBILI') && (
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
title={'bilibili'}
|
||||
href={siteConfig('CONTACT_BILIBILI')}>
|
||||
<i className='fab fa-bilibili transform hover:scale-125 duration-150' />
|
||||
</a>
|
||||
)}
|
||||
{siteConfig('CONTACT_YOUTUBE') && (
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
title={'youtube'}
|
||||
href={siteConfig('CONTACT_YOUTUBE')}>
|
||||
<i className='fab fa-youtube transform hover:scale-125 duration-150' />
|
||||
</a>
|
||||
)}
|
||||
{siteConfig('CONTACT_THREADS') && (
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
title={'threads'}
|
||||
href={siteConfig('CONTACT_THREADS')}>
|
||||
<i className='fab fa-threads transform hover:scale-125 duration-150' />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default SocialButton
|
19
mhhung/components/Title.js
Normal file
19
mhhung/components/Title.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { siteConfig } from '@/lib/config'
|
||||
|
||||
/**
|
||||
* 标题栏
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
export const Title = (props) => {
|
||||
const { post } = props
|
||||
const title = post?.title || siteConfig('DESCRIPTION')
|
||||
const description = post?.description || siteConfig('AUTHOR')
|
||||
|
||||
return <div className="text-center px-6 py-12 mb-6 bg-gray-100 dark:bg-hexo-black-gray dark:border-hexo-black-gray border-b">
|
||||
<h1 className=" text-xl md:text-4xl pb-4">{title}</h1>
|
||||
<p className="leading-loose text-gray-dark">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
}
|
19
mhhung/components/TopBar.js
Normal file
19
mhhung/components/TopBar.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import CONFIG from '../config'
|
||||
import { siteConfig } from '@/lib/config'
|
||||
|
||||
/**
|
||||
* 网站顶部 提示栏
|
||||
* @returns
|
||||
*/
|
||||
export default function TopBar (props) {
|
||||
const content = siteConfig('SIMPLE_TOP_BAR_CONTENT', null, CONFIG)
|
||||
|
||||
if (content) {
|
||||
return <header className="flex justify-center items-center bg-black dark:bg-hexo-black-gray">
|
||||
<div id='top-bar-inner' className='max-w-9/10 w-full z-20'>
|
||||
<div className='text-xs text-center float-left text-white z-50 leading-5 py-2.5' dangerouslySetInnerHTML={{ __html: content }}/>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
return <></>
|
||||
}
|
17
mhhung/config.js
Normal file
17
mhhung/config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const CONFIG = {
|
||||
// 博客標題 雙語言
|
||||
TYPOGRAPHY_BLOG_NAME: process.env.NEXT_PUBLIC_TYPOGRAPHY_BLOG_NAME || '活字印刷',
|
||||
TYPOGRAPHY_BLOG_NAME_EN: process.env.NEXT_PUBLIC_TYPOGRAPHY_BLOG_NAME_EN || process.env.NEXT_PUBLIC_TYPOGRAPHY_BLOG_NAME || 'Typography',
|
||||
|
||||
TYPOGRAPHY_POST_AD_ENABLE: process.env.NEXT_PUBLIC_TYPOGRAPHY_POST_AD_ENABLE || false, // 文章列表是否插入广告
|
||||
|
||||
TYPOGRAPHY_POST_COVER_ENABLE: process.env.NEXT_PUBLIC_TYPOGRAPHY_POST_COVER_ENABLE || false, // 是否展示博客封面
|
||||
|
||||
TYPOGRAPHY_ARTICLE_RECOMMEND_POSTS: process.env.NEXT_PUBLIC_TYPOGRAPHY_ARTICLE_RECOMMEND_POSTS || true, // 文章详情底部显示推荐
|
||||
|
||||
// 菜单配置
|
||||
TYPOGRAPHY_MENU_CATEGORY: true, // 显示分类
|
||||
TYPOGRAPHY_MENU_TAG: true, // 显示标签
|
||||
TYPOGRAPHY_MENU_ARCHIVE: true, // 显示归档
|
||||
}
|
||||
export default CONFIG
|
372
mhhung/index.js
Normal file
372
mhhung/index.js
Normal file
@@ -0,0 +1,372 @@
|
||||
import { AdSlot } from '@/components/GoogleAdsense'
|
||||
import replaceSearchResult from '@/components/Mark'
|
||||
import NotionPage from '@/components/NotionPage'
|
||||
import { siteConfig } from '@/lib/config'
|
||||
import { useGlobal } from '@/lib/global'
|
||||
import { isBrowser } from '@/lib/utils'
|
||||
import dynamic from 'next/dynamic'
|
||||
import SmartLink from '@/components/SmartLink'
|
||||
import { useRouter } from 'next/router'
|
||||
import { createContext, useContext, useEffect, useRef } from 'react'
|
||||
import BlogPostBar from './components/BlogPostBar'
|
||||
import CONFIG from './config'
|
||||
import { Style } from './style'
|
||||
import Catalog from './components/Catalog'
|
||||
|
||||
const AlgoliaSearchModal = dynamic(
|
||||
() => import('@/components/AlgoliaSearchModal'),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
// 主题组件
|
||||
|
||||
const BlogArchiveItem = dynamic(() => import('./components/BlogArchiveItem'), {
|
||||
ssr: false
|
||||
})
|
||||
const ArticleLock = dynamic(() => import('./components/ArticleLock'), {
|
||||
ssr: false
|
||||
})
|
||||
const ArticleInfo = dynamic(() => import('./components/ArticleInfo'), {
|
||||
ssr: false
|
||||
})
|
||||
const Comment = dynamic(() => import('@/components/Comment'), { ssr: false })
|
||||
const ArticleAround = dynamic(() => import('./components/ArticleAround'), {
|
||||
ssr: false
|
||||
})
|
||||
const TopBar = dynamic(() => import('./components/TopBar'), { ssr: false })
|
||||
const NavBar = dynamic(() => import('./components/NavBar'), { ssr: false })
|
||||
const JumpToTopButton = dynamic(() => import('./components/JumpToTopButton'), {
|
||||
ssr: false
|
||||
})
|
||||
const Footer = dynamic(() => import('./components/Footer'), { ssr: false })
|
||||
const WWAds = dynamic(() => import('@/components/WWAds'), { ssr: false })
|
||||
const BlogListPage = dynamic(() => import('./components/BlogListPage'), {
|
||||
ssr: false
|
||||
})
|
||||
const RecommendPosts = dynamic(() => import('./components/RecommendPosts'), {
|
||||
ssr: false
|
||||
})
|
||||
|
||||
// 主题全局状态
|
||||
const ThemeGlobalSimple = createContext()
|
||||
export const useSimpleGlobal = () => useContext(ThemeGlobalSimple)
|
||||
|
||||
/**
|
||||
* 基础布局
|
||||
*
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const LayoutBase = props => {
|
||||
const { children } = props
|
||||
const { onLoading, fullWidth } = useGlobal()
|
||||
// const onLoading = true
|
||||
const searchModal = useRef(null)
|
||||
|
||||
return (
|
||||
<ThemeGlobalSimple.Provider value={{ searchModal }}>
|
||||
<div
|
||||
id='theme-typography'
|
||||
className={`${siteConfig('FONT_STYLE')} font-typography h-screen flex flex-col dark:text-gray-300 bg-white dark:bg-[#232222] overflow-hidden`}>
|
||||
<Style />
|
||||
|
||||
{siteConfig('SIMPLE_TOP_BAR', null, CONFIG) && <TopBar {...props} />}
|
||||
|
||||
<div className='flex flex-1 mx-auto overflow-hidden py-8 md:p-0 md:max-w-7xl md:px-24 w-screen'>
|
||||
{/* 主体 - 使用 flex 布局 */}
|
||||
{/* 文章详情才显示 */}
|
||||
{/* {props.post && (
|
||||
<div className='mt-20 hidden md:block md:fixed md:left-5 md:w-[300px]'>
|
||||
<Catalog {...props} />
|
||||
</div>
|
||||
)} */}
|
||||
<div className='overflow-hidden md:mt-20 flex-1 '>
|
||||
{/* 左侧内容区域 - 可滚动 */}
|
||||
<div
|
||||
id='container-inner'
|
||||
className='h-full w-full md:px-24 overflow-y-auto scroll-hidden relative'>
|
||||
{/* 移动端导航 - 显示在顶部 */}
|
||||
<div className='md:hidden'>
|
||||
<NavBar {...props} />
|
||||
</div>
|
||||
{onLoading ? (
|
||||
// loading 时显示 spinner
|
||||
<div className='flex items-center justify-center min-h-[500px] w-full'>
|
||||
<div className='animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-gray-900 dark:border-white'></div>
|
||||
</div>
|
||||
) : (
|
||||
<>{children}</>
|
||||
)}
|
||||
<AdSlot type='native' />
|
||||
{/* 移动端页脚 - 显示在底部 */}
|
||||
<div className='md:hidden z-30 '>
|
||||
<Footer {...props} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧导航和页脚 - 固定不滚动 */}
|
||||
<div className='hidden md:flex md:flex-col md:flex-shrink-0 md:h-[100vh] sticky top-20'>
|
||||
<NavBar {...props} />
|
||||
<Footer {...props} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='fixed right-4 bottom-4 z-20'>
|
||||
<JumpToTopButton />
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<AlgoliaSearchModal cRef={searchModal} {...props} />
|
||||
</div>
|
||||
</ThemeGlobalSimple.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 博客首页
|
||||
* 首页就是列表
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const LayoutIndex = props => {
|
||||
return <LayoutPostList {...props} />
|
||||
}
|
||||
/**
|
||||
* 博客列表
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const LayoutPostList = props => {
|
||||
return (
|
||||
<>
|
||||
<BlogPostBar {...props} />
|
||||
<BlogListPage {...props} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索页
|
||||
* 也是博客列表
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const LayoutSearch = props => {
|
||||
const { keyword } = props
|
||||
|
||||
useEffect(() => {
|
||||
if (isBrowser) {
|
||||
replaceSearchResult({
|
||||
doms: document.getElementById('posts-wrapper'),
|
||||
search: keyword,
|
||||
target: {
|
||||
element: 'span',
|
||||
className: 'text-red-500 border-b border-dashed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <LayoutPostList {...props} />
|
||||
}
|
||||
|
||||
function groupArticlesByYearArray(articles) {
|
||||
const grouped = {};
|
||||
|
||||
for (const article of articles) {
|
||||
const year = new Date(article.publishDate).getFullYear().toString();
|
||||
if (!grouped[year]) {
|
||||
grouped[year] = [];
|
||||
}
|
||||
grouped[year].push(article);
|
||||
}
|
||||
|
||||
for (const year in grouped) {
|
||||
grouped[year].sort((a, b) => b.publishDate - a.publishDate);
|
||||
}
|
||||
|
||||
// 转成数组并按年份倒序
|
||||
return Object.entries(grouped)
|
||||
.sort(([a], [b]) => b - a)
|
||||
.map(([year, posts]) => ({ year, posts }));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 归档页
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const LayoutArchive = props => {
|
||||
const { posts } = props
|
||||
const sortPosts = groupArticlesByYearArray(posts)
|
||||
return (
|
||||
<>
|
||||
<div className='mb-10 pb-20 md:pb-12 p-5 min-h-screen w-full'>
|
||||
{sortPosts.map(p => (
|
||||
<BlogArchiveItem
|
||||
key={p.year}
|
||||
archiveTitle={p.year}
|
||||
archivePosts={p.posts}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章详情
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const LayoutSlug = props => {
|
||||
const { post, lock, validPassword, prev, next, recommendPosts } = props
|
||||
const { fullWidth } = useGlobal()
|
||||
|
||||
return (
|
||||
<>
|
||||
{lock && <ArticleLock validPassword={validPassword} />}
|
||||
|
||||
{!lock && post && (
|
||||
<div
|
||||
className={`px-5 pt-3 ${fullWidth ? '' : 'xl:max-w-4xl 2xl:max-w-6xl'}`}>
|
||||
{/* 文章信息 */}
|
||||
<ArticleInfo post={post} />
|
||||
|
||||
{/* 广告嵌入 */}
|
||||
{/* <AdSlot type={'in-article'} /> */}
|
||||
<WWAds orientation='horizontal' className='w-full' />
|
||||
|
||||
<div id='article-wrapper'>
|
||||
{/* Notion 文章主体 */}
|
||||
{!lock && <NotionPage post={post} />}
|
||||
</div>
|
||||
|
||||
{/* 分享 */}
|
||||
{/* <ShareBar post={post} /> */}
|
||||
|
||||
{/* 广告嵌入 */}
|
||||
<AdSlot type={'in-article'} />
|
||||
|
||||
{post?.type === 'Post' && (
|
||||
<>
|
||||
<ArticleAround prev={prev} next={next} />
|
||||
<RecommendPosts recommendPosts={recommendPosts} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 评论区 */}
|
||||
<Comment frontMatter={post} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 404
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const Layout404 = props => {
|
||||
const { post } = props
|
||||
const router = useRouter()
|
||||
const waiting404 = siteConfig('POST_WAITING_TIME_FOR_404') * 1000
|
||||
useEffect(() => {
|
||||
// 404
|
||||
if (!post) {
|
||||
setTimeout(() => {
|
||||
if (isBrowser) {
|
||||
const article = document.querySelector(
|
||||
'#article-wrapper #notion-article'
|
||||
)
|
||||
if (!article) {
|
||||
router.push('/404').then(() => {
|
||||
console.warn('找不到页面', router.asPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, waiting404)
|
||||
}
|
||||
}, [post])
|
||||
return <>404 Not found.</>
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类列表
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const LayoutCategoryIndex = props => {
|
||||
const { categoryOptions } = props
|
||||
return (
|
||||
<>
|
||||
<div id='category-list' className='px-5 duration-200 flex flex-wrap'>
|
||||
{categoryOptions?.map(category => {
|
||||
return (
|
||||
<SmartLink
|
||||
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>
|
||||
</SmartLink>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签列表
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const LayoutTagIndex = props => {
|
||||
const { tagOptions } = props
|
||||
return (
|
||||
<>
|
||||
<div id='tags-list' className='px-5 duration-200 flex flex-wrap'>
|
||||
{tagOptions.map(tag => {
|
||||
return (
|
||||
<div key={tag.name} className='p-2'>
|
||||
<SmartLink
|
||||
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>
|
||||
</SmartLink>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Layout404,
|
||||
LayoutArchive,
|
||||
LayoutBase,
|
||||
LayoutCategoryIndex,
|
||||
LayoutIndex,
|
||||
LayoutPostList,
|
||||
LayoutSearch,
|
||||
LayoutSlug,
|
||||
LayoutTagIndex,
|
||||
CONFIG as THEME_CONFIG
|
||||
}
|
93
mhhung/style.js
Normal file
93
mhhung/style.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/* eslint-disable react/no-unknown-property */
|
||||
/**
|
||||
* 此处样式只对当前主题生效
|
||||
* 此处不支持 tailwindCSS 的 @apply 语法
|
||||
* @returns
|
||||
*/
|
||||
const Style = () => {
|
||||
return (
|
||||
<style jsx global>{`
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.font-typography {
|
||||
font-weight: 400;
|
||||
font-family:
|
||||
Source Sans Pro,
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Helvetica Neue,
|
||||
Source Han Sans SC,
|
||||
Source Han Sans TC,
|
||||
PingFang SC,
|
||||
PingFang HK,
|
||||
PingFang TC,
|
||||
sans-serif !important;
|
||||
}
|
||||
}
|
||||
// 底色
|
||||
.dark body {
|
||||
background-color: rgb(35, 34, 34);
|
||||
}
|
||||
// 文本不可选取
|
||||
.forbid-copy {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
.dark #theme-typography {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
rgb(255 255 255 / 0.04) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(to bottom, rgb(255 255 255 / 0.04) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
#theme-typography {
|
||||
--primary-color: #2e405b;
|
||||
background-color: rgb(255 255 255) / 1;
|
||||
color: #2e405b;
|
||||
background-size: 7px 7px;
|
||||
text-shadow: 1px 1px 1px rgb(0 0 0 / 0.04);
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
rgb(0 0 0 / 0.04) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(to bottom, rgb(0 0 0 / 0.04) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
#theme-typography #blog-name {
|
||||
font-family: HiraMinProN-W6, 'Source Han Serif CN',
|
||||
'Source Han Serif SC', 'Source Han Serif TC', serif;
|
||||
}
|
||||
|
||||
#theme-typography #blog-name-en {
|
||||
font-family: HiraMinProN-W6, 'Source Han Serif CN',
|
||||
'Source Han Serif SC', 'Source Han Serif TC', serif;
|
||||
}
|
||||
|
||||
#theme-typography .blog-item-title {
|
||||
color: #276077;
|
||||
}
|
||||
|
||||
.dark #theme-typography .blog-item-title {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.notion {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
#container-wrapper .scroll-hidden {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
`}</style>
|
||||
)
|
||||
}
|
||||
|
||||
export { Style }
|
Reference in New Issue
Block a user