Merge pull request #2923 from tangly1024/release/4.7.7

Release/4.7.7
This commit is contained in:
tangly1024
2024-11-04 16:53:31 +08:00
committed by GitHub
54 changed files with 2256 additions and 4 deletions

View File

@@ -1,5 +1,5 @@
# 环境变量 @see https://www.nextjs.cn/docs/basic-features/environment-variables
NEXT_PUBLIC_VERSION=4.7.6
NEXT_PUBLIC_VERSION=4.7.7
# 可在此添加环境变量,去掉最左边的(# )注释即可

View File

@@ -65,8 +65,8 @@ export async function getNotionPageData({ pageId, from }) {
const cacheKey = 'page_block_' + pageId
let data = await getDataFromCache(cacheKey)
if (data && data.pageIds?.length > 0) {
// console.log('[API<<--缓存]', `from:${from}`, `root-page-id:${pageId}`)
// return data
console.debug('[API<<--缓存]', `from:${from}`, `root-page-id:${pageId}`)
return data
} else {
// 从接口读取
data = await getDataBaseInfoByNotionAPI({ pageId, from })

View File

@@ -1,6 +1,6 @@
{
"name": "notion-next",
"version": "4.7.6",
"version": "4.7.7",
"homepage": "https://github.com/tangly1024/NotionNext.git",
"license": "MIT",
"repository": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1020 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 665 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 915 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 783 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 792 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -2,6 +2,10 @@
@tailwind components;
@tailwind utilities;
html {
overflow-x: hidden;
}
.wrapper {
min-height: 100vh;
display: flex;

View File

@@ -0,0 +1,23 @@
import dynamic from 'next/dynamic'
const NotionPage = dynamic(() => import('@/components/NotionPage'))
/**
* 公告
* @param {*} param0
* @returns
*/
const Announcement = ({ notice, className }) => {
if (!notice || Object.keys(notice).length === 0) {
return <></>
}
return (
<aside className={className}>
{notice && (
<div id='announcement-content'>
<NotionPage post={notice} className='text-center ' />
</div>
)}
</aside>
)
}
export default Announcement

View File

@@ -0,0 +1,38 @@
import { useGlobal } from '@/lib/global'
import { formatDateFmt } from '@/lib/utils/formatDate'
import Link from 'next/link'
export default function ArchiveDateList(props) {
const postsSortByDate = Object.create(props.allNavPages)
const { locale } = useGlobal()
postsSortByDate.sort((a, b) => {
return b?.publishDate - a?.publishDate
})
let dates = []
postsSortByDate.forEach(post => {
const date = formatDateFmt(post.publishDate, 'yyyy-MM')
if (!dates[date]) {
dates.push(date)
}
})
dates = dates.slice(0, 5)
return (
<div>
<div className="text-2xl dark:text-white mb-2">{locale.NAV.ARCHIVE}</div>
{dates?.map((date, index) => {
return (
<div key={index}>
<Link
href={`/archive#${date}`}
className="hover:underline dark:text-green-500"
>
{date}
</Link>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { useGlobal } from '@/lib/global'
import { formatDateFmt } from '@/lib/utils/formatDate'
import Link from 'next/link'
/**
* 文章页脚
* @param {*} props
* @returns
*/
export default function ArticleFooter(props) {
const { post } = props
const { locale } = useGlobal()
return (
<>
{/* 分类和标签部分 */}
<div className='flex gap-3 font-semibold text-sm items-center justify-center'>
{/* 分类标签(如果文章不是“页面”类型) */}
{post?.type !== 'Page' && (
<>
<Link
href={`/category/${post?.category}`}
passHref
className='cursor-pointer text-md mr-2 text-green-500'>
{post?.category}
</Link>
</>
)}
{/* 标签部分(若文章有标签) */}
<div className='flex py-1 space-x-3'>
{post?.tags?.length > 0 && (
<>
{locale.COMMON.TAGS} <span>:</span>
</>
)}
{/* 显示所有标签 */}
{post?.tags?.map(tag => {
return (
<Link
href={`/tag/${tag}`}
key={tag}
className='text-yellow-500 mr-2'>
{tag}
</Link>
)
})}
</div>
</div>
{/* 发布日期信息 */}
{/* 将发布日期移至文章底部并设置样式 */}
<div
className='text-center mt-6'
style={{
fontSize: '12px', // 设置字体大小为 12px
fontWeight: '300', // 设置字体粗细为细体
color: 'gray' // 设置文字颜色为灰色
}}>
<Link
href={`/archive#${formatDateFmt(post?.publishDate, 'yyyy-MM')}`}
passHref
className='pl-1 cursor-pointer'>
{post?.publishDay}
</Link>
</div>
</>
)
}

View File

@@ -0,0 +1,23 @@
/**
* 文章页头
* @param {*} props
* @returns
*/
export const ArticleHeader = props => {
const { post } = props
return (
<section className='w-full mx-auto mb-4'>
{/* 标题部分 */}
{/* 将标题字体大小设置为 16px并将字体粗细设置为细体 */}
<h2
className='py-10 dark:text-white text-center'
style={{
fontSize: '16px', // 设置字体大小为 16px
fontWeight: '300' // 设置字体粗细为细体
}}>
{post?.title}
</h2>
</section>
)
}

View 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 const ArticleLock = props => {
const { validPassword } = props
const { locale } = useGlobal()
const submitPassword = () => {
const p = document.getElementById('password')
if (!validPassword(p?.value)) {
const tips = document.getElementById('tips')
if (tips) {
tips.innerHTML = ''
tips.innerHTML = `<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'} >&nbsp;{locale.COMMON.SUBMIT}</i>
</div>
</div>
<div id='tips'>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,36 @@
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 => {
return (
<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?.publishDay}>
<span className='text-gray-400'>{post?.publishDay}</span> &nbsp;
<Link
href={post?.href}
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>
)
}

View File

@@ -0,0 +1,31 @@
import { siteConfig } from '@/lib/config'
import { useGlobal } from '@/lib/global'
import CONFIG from '../config'
import BlogPostCard from './BlogPostCard'
import PaginationNumber from './PaginationNumber'
export const BlogListPage = props => {
const { page = 1, posts, postCount } = props
const { NOTION_CONFIG } = useGlobal()
const POSTS_PER_PAGE = siteConfig('POSTS_PER_PAGE', null, NOTION_CONFIG)
const totalPage = Math.ceil(postCount / POSTS_PER_PAGE)
const showPageCover = siteConfig('MOVIE_POST_LIST_COVER', null, CONFIG)
if (!posts || posts.length === 0) {
return null
}
return (
<div className={`w-full ${showPageCover ? 'md:pr-2' : 'md:pr-12'} py-6`}>
<div
id='posts-wrapper'
className='grid md:grid-cols-2 md:gap-12 lg:grid-cols-3 lg:gap-20 xl:gap-24 2xl:grid-cols-4'>
{posts?.map(post => (
<BlogPostCard key={post.id} post={post} />
))}
</div>
<PaginationNumber page={page} totalPage={totalPage} />
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { siteConfig } from '@/lib/config'
import { useGlobal } from '@/lib/global'
import throttle from 'lodash.throttle'
import { useCallback, useEffect, useRef, useState } from 'react'
import CONFIG from '../config'
import BlogPostCard from './BlogPostCard'
export const BlogListScroll = props => {
const { posts } = props
const { locale } = useGlobal()
const [page, updatePage] = useState(1)
let hasMore = false
const postsToShow = posts
? Object.assign(posts).slice(
0,
parseInt(siteConfig('POSTS_PER_PAGE', 12, props?.NOTION_CONFIG)) * page
)
: []
if (posts) {
const totalCount = posts.length
hasMore =
page * parseInt(siteConfig('POSTS_PER_PAGE', 12, props?.NOTION_CONFIG)) <
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)
)
const showPageCover = siteConfig('MOVIE_POST_LIST_COVER', null, CONFIG)
useEffect(() => {
window.addEventListener('scroll', scrollTrigger)
return () => {
window.removeEventListener('scroll', scrollTrigger)
}
})
return (
<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} />
))}
<div
onClick={handleGetMore}
className='w-full my-4 py-4 text-center cursor-pointer '>
{' '}
{hasMore ? locale.COMMON.MORE : `${locale.COMMON.NO_MORE} 😰`}{' '}
</div>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import LazyImage from '@/components/LazyImage'
import NotionIcon from '@/components/NotionIcon'
import { siteConfig } from '@/lib/config'
import Link from 'next/link'
import TagItemMini from './TagItemMini'
const BlogPostCard = ({ index, post, showSummary, siteInfo }) => {
// 主题默认强制显示图片
if (post && !post.pageCoverThumbnail) {
post.pageCoverThumbnail =
siteInfo?.pageCover || siteConfig('RANDOM_IMAGE_URL')
}
return (
<article
data-wow-delay='.2s'
className='wow fadeInUp w-full mb-4 cursor-pointer overflow-hidden shadow-movie dark:bg-hexo-black-gray'>
<Link href={post?.href} passHref legacyBehavior>
{/* 固定高度 ,空白用图片拉升填充 */}
<div className='group flex flex-col aspect-[2/3] justify-between relative'>
{/* 图片 填充卡片 */}
<div className='flex flex-grow w-full h-full relative duration-200 cursor-pointer transform overflow-hidden'>
<LazyImage
src={post?.pageCoverThumbnail}
alt={post.title}
className='h-full w-full group-hover:brightness-90 group-hover:scale-105 transform object-cover duration-500'
/>
</div>
<div className='absolute bottom-28 z-20'>
{post?.tagItems && post?.tagItems.length > 0 && (
<>
<div className='px-6 justify-between flex p-2'>
{post.tagItems.map(tag => (
<TagItemMini key={tag.name} tag={tag} />
))}
</div>
</>
)}
</div>
{/* 阴影遮罩 */}
<h2 className='absolute bottom-10 px-6 transition-all duration-200 text-white text-2xl font-semibold break-words shadow-text z-20'>
{siteConfig('POST_TITLE_ICON') && (
<NotionIcon icon={post.pageIcon} />
)}
{post.title}
</h2>
<p className='absolute bottom-3 z-20 line-clamp-1 text-xs mx-6'>
{post?.summary}
</p>
<div className='h-3/4 w-full absolute left-0 bottom-0 z-10'>
<div className='h-full w-full absolute opacity-80 group-hover:opacity-100 transition-all duration-1000 bg-gradient-to-b from-transparent to-black'></div>
</div>
</div>
</Link>
</article>
)
}
export default BlogPostCard

View File

@@ -0,0 +1,66 @@
import LazyImage from '@/components/LazyImage'
import { siteConfig } from '@/lib/config'
import { useGlobal } from '@/lib/global'
import Link from 'next/link'
import CONFIG from '../config'
/**
* 关联推荐文章
* @param {prev,next} param0
* @returns
*/
export default function BlogRecommend(props) {
const { recommendPosts, siteInfo } = props
const { locale } = useGlobal()
if (
!siteConfig('MOVIE_ARTICLE_RECOMMEND', null, CONFIG) ||
!recommendPosts ||
recommendPosts.length === 0
) {
return <></>
}
return (
<div className='py-8'>
<div className=' mb-2 px-1 flex flex-nowrap justify-between'>
<div className='dark:text-gray-300'>
<i className='mr-2 fas fa-thumbs-up' />
{locale.COMMON.RELATE_POSTS}
</div>
</div>
<div className='flex flex-nowrap gap-4'>
{recommendPosts.map(post => {
const headerImage = post?.pageCoverThumbnail
? post.pageCoverThumbnail
: siteInfo?.pageCover
return (
<Link
key={post.id}
title={post.title}
href={post?.href}
passHref
className='flex rounded-lg h-60 w-48 cursor-pointer overflow-hidden'>
<div className='h-full w-full relative group shadow-movie'>
<div className='absolute bottom-4 w-full z-20 duration-300 '>
<div className='z-10 text-lg px-4 font-bold text-white shadow-text select-none'>
{post.title}
</div>
</div>
{/* 卡片的阴影遮罩,为了凸显图片上的文字 */}
<div className='h-3/4 w-full absolute left-0 bottom-0 z-10'>
<div className='h-full w-full absolute opacity-80 group-hover:opacity-100 transition-all duration-1000 bg-gradient-to-b from-transparent to-black'></div>
</div>
<LazyImage
src={headerImage}
className='absolute top-0 w-full h-full object-cover object-center group-hover:scale-110 group-hover:brightness-50 transform duration-200'
/>
</div>
</Link>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import { siteConfig } from '@/lib/config'
import { useGlobal } from '@/lib/global'
import Link from 'next/link'
const CategoryGroup = props => {
const { currentCategory, categoryOptions } = props
const { locale } = useGlobal()
if (!categoryOptions || categoryOptions.length === 0) return <></>
const categoryCount = siteConfig('PREVIEW_CATEGORY_COUNT')
const categories = categoryOptions.slice(0, categoryCount)
return (
<>
<div>
<h2 className="text-2xl dark:text-white">{locale.COMMON.CATEGORY}</h2>
<div id="category-list" className="dark:border-gray-600 flex flex-col">
{categories.map(category => {
const selected = currentCategory === category.name
return (
<Link
key={category.name}
href={`/category/${category.name}`}
passHref
className={
(selected
? 'hover:text-white dark:hover:text-white bg-gray-600 text-white '
: 'dark:text-green-400 text-gray-500 hover:text-white hover:bg-gray-500 dark:hover:text-white') +
' w-full items-center duration-300 px-2 cursor-pointer py-1 font-light'
}
>
<i
className={`${selected ? 'text-white fa-folder-open ' : 'text-gray-500 fa-folder '} mr-2 fas`}
/>
{category.name}({category.count})
</Link>
)
})}
</div>
</div>
</>
)
}
export default CategoryGroup

View 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={'text-2xl 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>
)
}

View File

@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react'
import { siteConfig } from '@/lib/config'
import Link from 'next/link'
import { RecentComments } from '@waline/client'
/**
* @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'><Link href={{ pathname: comment.url, hash: comment.objectId, query: { target: 'comment' } }}>--{comment.nick}</Link></div>
</div>)}
</>
}
export default ExampleRecentComments

View File

@@ -0,0 +1,48 @@
import { BeiAnGongAn } from '@/components/BeiAnGongAn'
import DarkModeButton from '@/components/DarkModeButton'
import { siteConfig } from '@/lib/config'
/**
* 页脚
* @param {*} props
* @returns
*/
export const Footer = props => {
const d = new Date()
const currentYear = d.getFullYear()
const since = siteConfig('SINCE')
const copyrightDate =
parseInt(since) < currentYear ? since + '-' + currentYear : currentYear
return (
<footer className='z-10 relative w-full bg-white px-6 dark:border-hexo-black-gray dark:bg-hexo-black-gray '>
<DarkModeButton className='text-center pt-4' />
<div className='container mx-auto max-w-4xl py-4 md:flex flex-wrap md:flex-no-wrap md:justify-between items-center text-sm'>
<div className='text-center'>
&copy;{`${copyrightDate}`} {siteConfig('AUTHOR')}. All rights
reserved.
</div>
<div className='md:p-0 text-center md:text-right text-xs'>
{/* 右侧链接 */}
{/* <a href="#" className="text-black no-underline hover:underline">Privacy Policy</a> */}
{siteConfig('BEI_AN') && (
<a
href='https://beian.miit.gov.cn/'
className='text-black dark:text-gray-200 no-underline hover:underline ml-4'>
{siteConfig('BEI_AN')}
</a>
)}
<BeiAnGongAn />
<span className='dark:text-gray-200 no-underline ml-4'>
Powered by
<a
href='https://github.com/tangly1024/NotionNext'
className=' hover:underline'>
NotionNext {siteConfig('VERSION')}
</a>
</span>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,27 @@
import { siteConfig } from '@/lib/config'
import Link from 'next/link'
import MenuHierarchical from './MenuHierarchical'
/**
* 网站顶部
* @returns
*/
export const Header = props => {
return (
<>
<header className='w-full px-8 h-20 z-30 flex lg:flex-row md:flex-col justify-center items-center'>
{/* 左侧Logo */}
<Link
href='/'
className='logo whitespace-nowrap text-2xl md:text-3xl text-gray-dark no-underline flex items-center'>
{siteConfig('TITLE')}
</Link>
{/* 右侧使用一个三级菜单 */}
<div className='ml-6 mt-7'>
<MenuHierarchical {...props} />
</div>
</header>
</>
)
}

View File

@@ -0,0 +1,21 @@
import LazyImage from '@/components/LazyImage'
import { siteConfig } from '@/lib/config'
import { useGlobal } from '@/lib/global'
/**
* 封面图
* @param {*} props
* @returns
*/
export const HomeBackgroundImage = props => {
const { siteInfo } = useGlobal()
const background = siteConfig('MOVIE_HOME_BACKGROUND')
if (!background) {
return null
}
return (
<LazyImage
className='-mt-20 w-screen h-screen pointer-events-none select-none object-cover'
src={siteInfo?.pageCover}
/>
)
}

View File

@@ -0,0 +1,18 @@
import { useGlobal } from '@/lib/global'
/**
* 跳转到网页顶部
* 当屏幕下滑500像素后会出现该控件
* @param targetRef 关联高度的目标html标签
* @param showPercent 是否显示百分比
* @returns {JSX.Element}
* @constructor
*/
const JumpToTopButton = () => {
const { locale } = useGlobal()
return <div title={locale.POST.TOP} className='cursor-pointer p-2 text-center' onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
><i className='fas fa-angle-up text-2xl' />
</div>
}
export default JumpToTopButton

View File

@@ -0,0 +1,57 @@
import { siteConfig } from '@/lib/config'
import { useGlobal } from '@/lib/global'
import Link from 'next/link'
import { useRouter } from 'next/router'
/**
* 最新文章列表
* @param posts 所有文章数据
* @param sliceCount 截取展示的数量 默认6
* @constructor
*/
const LatestPostsGroup = ({ latestPosts }) => {
// 获取当前路径
const currentPath = useRouter().asPath
const { locale } = useGlobal()
if (!latestPosts) {
return <></>
}
return (
<div>
<div className='pb-1 px-2 flex flex-nowrap justify-between'>
<div className='text-2xl text-gray-600 dark:text-gray-200'>
<i className='mr-2 fas fa-history' />
{locale.COMMON.LATEST_POSTS}
</div>
</div>
{latestPosts.map(post => {
const selected =
currentPath === `${siteConfig('SUB_PATH', '')}/${post.slug}`
return (
<Link
key={post.id}
title={post.title}
href={post?.href}
passHref
className={'my-1 flex'}>
<div
className={
(selected
? 'text-white bg-gray-600 '
: 'text-gray-500 dark:text-green-400 ') +
' py-1 flex hover:bg-gray-500 px-2 duration-200 w-full ' +
'hover:text-white dark:hover:text-white cursor-pointer'
}>
<li className='line-clamp-2'>{post.title}</li>
</div>
</Link>
)
})}
</div>
)
}
export default LatestPostsGroup

View 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>
}

View File

@@ -0,0 +1,114 @@
import Collapse from '@/components/Collapse'
import { siteConfig } from '@/lib/config'
import { useGlobal } from '@/lib/global'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { usePhotoGlobal } from '..'
import CONFIG from '../config'
import { MenuItemCollapse } from './MenuItemCollapse'
/**
* 三级菜单
*/
export default function MenuHierarchical(props) {
const router = useRouter()
const { customNav, customMenu } = props
const { locale } = useGlobal()
const [isOpen, setIsOpen] = useState(false)
const { collapseRef } = usePhotoGlobal()
const toggleMenuOpen = () => {
setIsOpen(!isOpen)
}
const closeModal = () => {
setIsOpen(false)
}
let links = [
{
id: 1,
icon: 'fa-solid fa-house',
name: locale.NAV.INDEX,
href: '/',
show: siteConfig('MOVIE_MENU_INDEX', null, CONFIG)
},
{
id: 2,
icon: 'fas fa-search',
name: locale.NAV.SEARCH,
href: '/search',
show: siteConfig('MOVIE_MENU_SEARCH', null, CONFIG)
},
{
id: 3,
icon: 'fas fa-archive',
name: locale.NAV.ARCHIVE,
href: '/archive',
show: siteConfig('MOVIE_MENU_ARCHIVE', null, CONFIG)
}
]
if (customNav) {
links = links.concat(customNav)
}
for (let i = 0; i < links.length; i++) {
if (links[i].id !== i) {
links[i].id = i
}
}
// 如果 开启自定义菜单则覆盖Page生成的菜单
if (siteConfig('CUSTOM_MENU')) {
links = customMenu
}
const [title, setTitle] = useState(siteConfig('BIO'))
useEffect(() => {
const currentLink = links.find(link => link.href === router.pathname)
if (currentLink) {
setTitle(currentLink.name)
}
closeModal()
}, [router])
return (
<div className='absolute top-0 mt-7 italic text-gray-700 dark:text-gray-200'>
{/* 菜单按钮 */}
<div
onClick={toggleMenuOpen}
className=' whitespace-nowrap cursor-pointer'>
{title}
</div>
<Collapse
className='z-50'
collapseRef={collapseRef}
type='vertical'
isOpen={isOpen}>
{/* 移动端菜单 */}
<menu id='nav-menu-mobile' className='my-4 space-y-4 justify-start'>
{links?.map(
(link, index) =>
link &&
link.show && (
<MenuItemCollapse
onHeightChange={param =>
collapseRef.current?.updateCollapseHeight(param)
}
key={index}
link={link}
/>
)
)}
</menu>
</Collapse>
{/* 遮罩 */}
{isOpen && (
<div
onClick={closeModal}
className='-z-10 fixed top-0 left-0 w-full h-full flex items-center justify-center bg-glassmorphism'
/>
)}
</div>
)
}

View File

@@ -0,0 +1,81 @@
import Collapse from '@/components/Collapse'
import Link from 'next/link'
import { useState } from 'react'
/**
* 折叠菜单
* @param {*} param0
* @returns
*/
export const MenuItemCollapse = props => {
const { link } = props
const [show, changeShow] = useState(false)
const hasSubMenu = link?.subMenus?.length > 0
const [isOpen, changeIsOpen] = useState(false)
const toggleShow = () => {
changeShow(!show)
}
const toggleOpenSubMenu = () => {
changeIsOpen(!isOpen)
}
if (!link || !link.show) {
return null
}
return (
<>
<div className='select-none w-full text-left' onClick={toggleShow}>
{!hasSubMenu && (
<Link
href={link?.href}
target={link?.target}
className='flex justify-between no-underline tracking-widest'>
<span className=' transition-all items-center duration-200'>
{link?.icon && <i className={link.icon + ' mr-4'} />}
{link?.name}
</span>
</Link>
)}
{hasSubMenu && (
<div
onClick={hasSubMenu ? toggleOpenSubMenu : null}
className='flex items-center justify-between cursor-pointer no-underline tracking-widest'>
<span className='transition-all items-center duration-200'>
{link?.icon && <i className={link.icon + ' mr-4'} />}
{link?.name}
</span>
<i
className={`select-none px-2 fas fa-chevron-left transition-all duration-200 ${isOpen ? '-rotate-90' : ''} `}></i>
</div>
)}
</div>
{/* 折叠子菜单 */}
{hasSubMenu && (
<Collapse
isOpen={isOpen}
onHeightChange={props.onHeightChange}
className='rounded-xl'>
{link.subMenus.map((sLink, index) => {
return (
<div
key={index}
className='dark:text-gray-200 text-left px-3 justify-start py-1 tracking-widest transition-all duration-200 pr-6'>
<Link href={sLink.href} target={link?.target}>
<span className='ml-4 whitespace-nowrap'>
{link?.icon && <i className={sLink.icon + ' mr-2'} />}{' '}
{sLink.title}
</span>
</Link>
</div>
)
})}
</Collapse>
)}
</>
)
}

View File

@@ -0,0 +1,59 @@
import Link from 'next/link'
import { useState } from 'react'
export const MenuItemDrop = ({ link }) => {
const [show, changeShow] = useState(false)
const hasSubMenu = link?.subMenus?.length > 0
if (!link || !link.show) {
return null
}
return (
<div
onMouseOver={() => changeShow(true)}
onMouseOut={() => changeShow(false)}>
{!hasSubMenu && (
<Link
href={link?.href}
target={link?.target}
className='select-none menu-link pl-2 pr-4 no-underline tracking-widest pb-1 hover:font-bold'>
{link?.icon && <i className={link?.icon} />} {link?.name}
{hasSubMenu && <i className='px-2 fa fa-angle-down'></i>}
</Link>
)}
{hasSubMenu && (
<>
<div className='cursor-pointer menu-link pl-2 pr-4 no-underline tracking-widest pb-1 hover:font-bold'>
{link?.icon && <i className={link?.icon} />} {link?.name}
<i
className={`px-2 fa fa-angle-down duration-300 ${show ? 'rotate-180' : 'rotate-0'}`}></i>
</div>
</>
)}
{/* 子菜单 */}
{hasSubMenu && (
<ul
style={{ backdropFilter: 'blur(3px)' }}
className={`${show ? 'visible opacity-100 top-14' : 'invisible opacity-0 top-20'} drop-shadow-md overflow-hidden rounded-md text-black dark:text-white bg-white dark:bg-black transition-all duration-300 z-30 absolute block `}>
{link.subMenus.map((sLink, index) => {
return (
<li
key={index}
className='cursor-pointer text-start dark:bg-hexo-black-gray dark:hover:bg-gray-300 hover:bg-gray-300 hover:text-black tracking-widest transition-all duration-200 dark:border-gray-800 py-1 pr-6 pl-3'>
<Link href={sLink.href} target={link?.target}>
<span className='text-sm'>
{link?.icon && <i className={sLink?.icon}> &nbsp; </i>}
{sLink.title}
</span>
</Link>
</li>
)
})}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,20 @@
import Link from 'next/link'
/**
* 旧的普通菜单
* @param {*} props
* @returns
*/
export const NormalMenuItem = props => {
const { link } = props
return (
link?.show && (
<Link
href={link.href}
key={link.href}
className='px-2 md:pl-0 md:mr-3 my-4 md:pr-3 text-gray-700 dark:text-gray-200 no-underline md:border-r border-gray-light'>
{link.name}
</Link>
)
)
}

View File

@@ -0,0 +1,214 @@
import { ChevronDoubleRight } from '@/components/HeroIcons'
import { useGlobal } from '@/lib/global'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
/**
* 数字翻页插件
* @param page 当前页码
* @param showNext 是否有下一页
* @returns {JSX.Element}
* @constructor
*/
const PaginationNumber = ({ page, totalPage }) => {
const router = useRouter()
const [value, setValue] = useState('')
const { locale } = useGlobal()
const currentPage = +page
const showNext = page < totalPage
const showPrev = currentPage !== 1
const pagePrefix = router.asPath
.split('?')[0]
.replace(/\/page\/[1-9]\d*/, '')
.replace(/\/$/, '')
.replace('.html', '')
const pages = generatePages(pagePrefix, page, currentPage, totalPage)
if (pages?.length <= 1) {
return <></>
}
const handleInputChange = event => {
const newValue = event.target.value.replace(/[^0-9]/g, '')
setValue(newValue)
}
/**
* 调到指定页
*/
const jumpToPage = () => {
if (value) {
router.push(
value === 1 ? `${pagePrefix}/` : `${pagePrefix}/page/${value}`
)
}
}
return (
<>
{/* pc端分页按钮 */}
<div className='hidden lg:flex justify-between items-end mt-10 mb-5 font-medium text-black duration-500 dark:text-gray-300 py-3 space-x-2 overflow-x-auto'>
{/* 上一页 */}
<Link
href={{
pathname:
currentPage === 2
? `${pagePrefix}/`
: `${pagePrefix}/page/${currentPage - 1}`,
query: router.query.s ? { s: router.query.s } : {}
}}
rel='prev'
className={`${currentPage === 1 ? 'invisible' : 'block'}`}>
<div className='relative w-24 h-10 flex items-center transition-all duration-200 justify-center py-2 px-2 bg-white dark:bg-[#1e1e1e] border rounded-lg cursor-pointer group'>
<i className='fas fa-angle-left mr-2 transition-all duration-200 transform group-hover:-translate-x-4' />
<div className='absolute translate-x-4 ml-2 opacity-0 transition-all duration-200 group-hover:opacity-100 group-hover:translate-x-0'>
{locale.PAGINATION.PREV}
</div>
</div>
</Link>
{/* 分页 */}
<div className='flex items-center space-x-2'>
{pages}
{/* 跳转页码 */}
<div className='bg-white hover:bg-gray-100 dark:hover:bg-yellow-600 dark:bg-[#1e1e1e] h-10 border flex justify-center items-center rounded-lg group hover:border-indigo-600 transition-all duration-200'>
<input
value={value}
className='w-0 group-hover:w-20 group-hover:px-3 transition-all duration-200 bg-gray-100 border-none outline-none h-full rounded-lg'
onInput={handleInputChange}></input>
<div
onClick={jumpToPage}
className='cursor-pointer hover:bg-indigo-600 dark:bg-[#1e1e1e] dark:hover:bg-yellow-600 hover:text-white px-4 py-2 group-hover:px-2 group-hover:mx-1 group-hover:rounded bg-white'>
<ChevronDoubleRight className={'w-4 h-4'} />
</div>
</div>
</div>
{/* 下一页 */}
<Link
href={{
pathname: `${pagePrefix}/page/${currentPage + 1}`,
query: router.query.s ? { s: router.query.s } : {}
}}
rel='next'
className={`${+showNext ? 'block' : 'invisible'} `}>
<div className='relative w-24 h-10 flex items-center transition-all duration-200 justify-center py-2 px-2 bg-white dark:bg-[#1e1e1e] border rounded-lg cursor-pointer group'>
<i className='fas fa-angle-right mr-2 transition-all duration-200 transform group-hover:translate-x-6' />
<div className='absolute -translate-x-10 ml-2 opacity-0 transition-all duration-200 group-hover:opacity-100 group-hover:-translate-x-2'>
{locale.PAGINATION.NEXT}
</div>
</div>
</Link>
</div>
{/* 移动端分页 */}
<div className='lg:hidden w-full flex flex-row'>
{/* 上一页 */}
<Link
href={{
pathname:
currentPage === 2
? `${pagePrefix}/`
: `${pagePrefix}/page/${currentPage - 1}`,
query: router.query.s ? { s: router.query.s } : {}
}}
rel='prev'
className={`${showPrev ? 'block' : 'hidden'} dark:text-white relative w-full flex-1 h-14 flex items-center transition-all duration-200 justify-center py-2 px-2 bg-white dark:bg-[#1e1e1e] border rounded-xl cursor-pointer`}>
{locale.PAGINATION.PREV}
</Link>
{showPrev && showNext && <div className='w-12'></div>}
{/* 下一页 */}
<Link
href={{
pathname: `${pagePrefix}/page/${currentPage + 1}`,
query: router.query.s ? { s: router.query.s } : {}
}}
rel='next'
className={`${+showNext ? 'block' : 'hidden'} dark:text-white relative w-full flex-1 h-14 flex items-center transition-all duration-200 justify-center py-2 px-2 bg-white dark:bg-[#1e1e1e] border rounded-xl cursor-pointer`}>
{locale.PAGINATION.NEXT}
</Link>
</div>
</>
)
}
/**
* 页码按钮
* @param {*} page
* @param {*} currentPage
* @param {*} pagePrefix
* @returns
*/
function getPageElement(page, currentPage, pagePrefix) {
const selected = page + '' === currentPage + ''
if (!page) {
return <></>
}
return (
<Link
href={page === 1 ? `${pagePrefix}/` : `${pagePrefix}/page/${page}`}
key={page}
passHref
className={
(selected
? 'bg-indigo-600 dark:bg-yellow-600 text-white '
: 'dark:bg-[#1e1e1e] bg-white') +
' hover:border-indigo-600 dark:hover:bg-yellow-600 dark:border-gray-600 px-4 border py-2 rounded-lg drop-shadow-sm duration-200 transition-colors'
}>
{page}
</Link>
)
}
/**
* 获取所有页码
* @param {*} pagePrefix
* @param {*} page
* @param {*} currentPage
* @param {*} totalPage
* @returns
*/
function generatePages(pagePrefix, page, currentPage, totalPage) {
const pages = []
const groupCount = 7 // 最多显示页签数
if (totalPage <= groupCount) {
for (let i = 1; i <= totalPage; i++) {
pages.push(getPageElement(i, page, pagePrefix))
}
} else {
pages.push(getPageElement(1, page, pagePrefix))
const dynamicGroupCount = groupCount - 2
let startPage = currentPage - 2
if (startPage <= 1) {
startPage = 2
}
if (startPage + dynamicGroupCount > totalPage) {
startPage = totalPage - dynamicGroupCount
}
if (startPage > 2) {
pages.push(
<div key={-1} className='-mt-2 mx-1'>
...{' '}
</div>
)
}
for (let i = 0; i < dynamicGroupCount; i++) {
if (startPage + i < totalPage) {
pages.push(getPageElement(startPage + i, page, pagePrefix))
}
}
if (startPage + dynamicGroupCount < totalPage) {
pages.push(<div key={-2}>... </div>)
}
pages.push(getPageElement(totalPage, page, pagePrefix))
}
return pages
}
export default PaginationNumber

View File

@@ -0,0 +1,55 @@
import LazyImage from '@/components/LazyImage'
import NotionIcon from '@/components/NotionIcon'
import { siteConfig } from '@/lib/config'
import { useGlobal } from '@/lib/global'
import { formatDateFmt } from '@/lib/utils/formatDate'
import Link from 'next/link'
/**
* 普通的博客卡牌
* 带封面图
*/
const PostItemCard = ({ post, className }) => {
const { siteInfo } = useGlobal()
const cover = post?.pageCoverThumbnail || siteInfo?.pageCover
return (
<div key={post?.id} className={className}>
<div className='space-y-3 relative justify-center items-center text-gray-500'>
<div className='h-full overflow-hidden'>
<LazyImage
alt={post?.title}
src={cover}
style={cover ? {} : { height: '0px' }}
className='h-full max-h-[70vh] object-cover select-none pointer-events-none'
/>
</div>
<div className='text-center'>
<Link
href={post?.href}
passHref
className={
'cursor-pointer hover:underline leading-tight dark:text-gray-300 '
}>
<h2 className='select-none pointer-events-none'>
{siteConfig('POST_TITLE_ICON') && (
<NotionIcon icon={post?.pageIcon} />
)}
{post?.title}
</h2>
</Link>
{/* 发布日期 */}
<Link
className='text-sm'
href={`/archive#${formatDateFmt(post?.publishDate, 'yyyy-MM')}`}
passHref>
{formatDateFmt(post?.publishDate, 'yyyy-MM')}
</Link>
</div>
</div>
</div>
)
}
export default PostItemCard

View File

@@ -0,0 +1,87 @@
import { useRouter } from 'next/router'
import { useGlobal } from '@/lib/global'
import { useImperativeHandle, useRef, useState } from 'react'
let lock = false
const SearchInput = ({ currentTag, keyword, cRef }) => {
const { locale } = useGlobal()
const router = useRouter()
const searchInputRef = useRef(null)
useImperativeHandle(cRef, () => {
return {
focus: () => {
searchInputRef?.current?.focus()
}
}
})
const handleSearch = () => {
const key = searchInputRef.current.value
if (key && key !== '') {
router.push({ pathname: '/search/' + key }).then(r => {
console.log('搜索', key)
})
} else {
router.push({ pathname: '/' }).then(r => {
})
}
}
const handleKeyUp = (e) => {
if (e.keyCode === 13) { // 回车
handleSearch(searchInputRef.current.value)
} else if (e.keyCode === 27) { // ESC
cleanSearch()
}
}
const cleanSearch = () => {
searchInputRef.current.value = ''
setShowClean(false)
}
function lockSearchInput () {
lock = true
}
function unLockSearchInput () {
lock = false
}
const [showClean, setShowClean] = useState(false)
const updateSearchKey = (val) => {
if (lock) {
return
}
searchInputRef.current.value = val
if (val) {
setShowClean(true)
} else {
setShowClean(false)
}
}
return <section className='flex w-full bg-gray-100'>
<input
ref={searchInputRef}
type='text'
placeholder={currentTag ? `${locale.SEARCH.TAGS} #${currentTag}` : `${locale.SEARCH.ARTICLES}`}
className={'outline-none w-full text-sm pl-4 transition focus:shadow-lg font-light leading-10 text-black bg-gray-100 dark:bg-gray-900 dark:text-white'}
onKeyUp={handleKeyUp}
onCompositionStart={lockSearchInput}
onCompositionUpdate={lockSearchInput}
onCompositionEnd={unLockSearchInput}
onChange={e => updateSearchKey(e.target.value)}
defaultValue={keyword || ''}
/>
<div className='-ml-8 cursor-pointer float-right items-center justify-center py-2'
onClick={handleSearch}>
<i className={'hover:text-black transform duration-200 text-gray-500 cursor-pointer fas fa-search'} />
</div>
{(showClean &&
<div className='-ml-12 cursor-pointer dark:bg-gray-600 dark:hover:bg-gray-800 float-right items-center justify-center py-2'>
<i className='hover:text-black transform duration-200 text-gray-400 cursor-pointer fas fa-times' onClick={cleanSearch} />
</div>
)}
</section>
}
export default SearchInput

View File

@@ -0,0 +1,68 @@
import { siteConfig } from '@/lib/config'
import Live2D from '@/components/Live2D'
import { useGlobal } from '@/lib/global'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import Announcement from './Announcement'
const ExampleRecentComments = dynamic(() => import('./ExampleRecentComments'))
export const SideBar = (props) => {
const { locale } = useGlobal()
const { latestPosts, categoryOptions, notice } = props
return (
<div className="w-full md:w-64 sticky top-8">
<aside className="rounded shadow overflow-hidden mb-6">
<h3 className="text-sm bg-gray-100 text-gray-700 dark:bg-hexo-black-gray dark:text-gray-200 py-3 px-4 dark:border-hexo-black-gray border-b">{locale.COMMON.CATEGORY}</h3>
<div className="p-4">
<ul className="list-reset leading-normal">
{categoryOptions?.map(category => {
return (
<Link
key={category.name}
href={`/category/${category.name}`}
passHref
legacyBehavior>
<li> <a href={`/category/${category.name}`} className="text-gray-darkest text-sm">{category.name}({category.count})</a></li>
</Link>
)
})}
</ul>
</div>
</aside>
<aside className="rounded shadow overflow-hidden mb-6">
<h3 className="text-sm bg-gray-100 text-gray-700 dark:bg-hexo-black-gray dark:text-gray-200 py-3 px-4 dark:border-hexo-black-gray border-b">{locale.COMMON.LATEST_POSTS}</h3>
<div className="p-4">
<ul className="list-reset leading-normal">
{latestPosts?.map(p => {
return (
<Link key={p.id} href={`/${p.slug}`} passHref legacyBehavior>
<li> <a href={`/${p.slug}`} className="text-gray-darkest text-sm">{p.title}</a></li>
</Link>
)
})}
</ul>
</div>
</aside>
<Announcement post={notice}/>
{siteConfig('COMMENT_WALINE_SERVER_URL') && siteConfig('COMMENT_WALINE_RECENT') && <aside className="rounded shadow overflow-hidden mb-6">
<h3 className="text-sm bg-gray-100 text-gray-700 dark:bg-hexo-black-gray dark:text-gray-200 py-3 px-4 dark:border-hexo-black-gray border-b">{locale.COMMON.RECENT_COMMENTS}</h3>
<div className="p-4">
<ExampleRecentComments/>
</div>
</aside>}
<aside className="rounded overflow-hidden mb-6">
<Live2D />
</aside>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { useGlobal } from '@/lib/global'
/**
* 博客列表上方嵌入条
* @param {*} props
* @returns
*/
export default function SlotBar(props) {
const { tag, category } = props
const { locale } = useGlobal()
if (tag) {
return (
<div className='cursor-pointer px-3 py-2 mb-2 '>
<div className={'inline-block rounded duration-200 mr-2 px-1 text-xl whitespace-nowrap '}>
<div className=' dark:text-white dark:hover:text-white text-5xl py-5'>
{locale.COMMON.TAGS} : {tag}{' '}
</div>
</div>
<hr className='dark:border-gray-600' />
</div>
)
} else if (category) {
return (
<div className='cursor-pointer px-3 py-2 mb-2 '>
<div className=' dark:text-white dark:hover:text-white text-5xl py-5'>
{locale.COMMON.CATEGORY} : {category}
</div>
<hr className='dark:border-gray-600' />
</div>
)
}
return <></>
}

View File

@@ -0,0 +1,106 @@
import { useEffect, useRef, useState } from 'react'
import PostItemCard from './PostItemCard'
/**
* 滑动走马灯
* @param {*} param0
* @returns
*/
const InertiaCarousel = ({ posts }) => {
const carouselRef = useRef(null)
const [isDragging, setIsDragging] = useState(false)
const [startX, setStartX] = useState(0)
const [scrollLeft, setScrollLeft] = useState(0)
const [lastX, setLastX] = useState(0) // 上一次的位置
const [velocity, setVelocity] = useState(0)
const animationRef = useRef(null)
// 开始拖拽事件
const startDrag = e => {
e.preventDefault()
setIsDragging(true)
const startPosition = e.pageX || e.touches?.[0].pageX
setStartX(startPosition - carouselRef.current.offsetLeft)
setScrollLeft(carouselRef.current.scrollLeft)
setLastX(startPosition) // 初始化上一次的位置
cancelInertiaScroll() // 停止任何正在进行的惯性动画
}
// 拖拽中事件
const duringDrag = e => {
if (!isDragging) return
e.preventDefault()
const currentPosition = e.pageX || e.touches[0].pageX
const distance = currentPosition - startX
carouselRef.current.scrollLeft = scrollLeft - distance
// 计算当前速度
const deltaX = currentPosition - lastX
setVelocity(deltaX) // 更新速度
setLastX(currentPosition) // 更新 lastX 为当前位置
}
// 结束拖拽事件,启动惯性滚动
const endDrag = () => {
setIsDragging(false)
startInertiaScroll(velocity) // 根据最终速度启动惯性滚动
}
// 惯性滚动函数
const startInertiaScroll = initialVelocity => {
let currentVelocity = initialVelocity
const decay = 0.95 // 惯性衰减系数
const animate = () => {
if (Math.abs(currentVelocity) > 0.5) {
// 仅当速度足够大时继续滚动
carouselRef.current.scrollLeft -= currentVelocity
currentVelocity *= decay // 速度衰减
animationRef.current = requestAnimationFrame(animate)
} else {
cancelAnimationFrame(animationRef.current)
}
}
animate()
}
// 取消惯性滚动
const cancelInertiaScroll = () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
useEffect(() => {
return () => cancelInertiaScroll() // 清除动画
}, [])
return (
<div
ref={carouselRef}
className={`flex w-screen overflow-x-auto space-x-6 ${
isDragging ? 'cursor-grabbing' : 'cursor-grab'
}`}
onMouseDown={startDrag}
onMouseMove={duringDrag}
onMouseUp={endDrag}
onMouseLeave={endDrag}
onTouchStart={startDrag}
onTouchMove={duringDrag}
onTouchEnd={endDrag}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
{/* Carousel items */}
<div className='min-w-[5vw] md:min-w-[27vw]' />
{posts &&
posts?.map((post, index) => (
<PostItemCard
className='min-w-[80vw] md:min-w-[50vw] w-full flex items-end justify-center'
key={index}
post={post}
/>
))}
</div>
)
}
export default InertiaCarousel

View File

@@ -0,0 +1,51 @@
import { useGlobal } from '@/lib/global'
import Link from 'next/link'
import { useRouter } from 'next/router'
/**
* 标签组
* @param tags
* @param currentTag
* @returns {JSX.Element}
* @constructor
*/
const TagGroups = ({ tagOptions, className }) => {
const router = useRouter()
const { locale } = useGlobal()
const { tag: currentTag } = router.query
if (!tagOptions) return <></>
return (
<div>
<div className="text-2xl dark:text-white mb-2">{locale.COMMON.TAGS}</div>
<div id="tags-group" className="dark:border-gray-700 space-y-2">
{tagOptions.map((tag, index) => {
const selected = currentTag === tag.name
return (
<Link
passHref
key={index}
href={`/tag/${encodeURIComponent(tag.name)}`}
className={'cursor-pointer inline-block whitespace-nowrap'}
>
<div
className={`${className || ''}
${selected ? 'text-white bg-blue-600 dark:bg-yellow-600' : ''}
flex items-center hover:bg-blue-600 dark:hover:bg-yellow-600 hover:scale-110 hover:text-white rounded-lg px-2 py-0.5 duration-150 transition-all`}
>
<div className="text-lg">{tag.name} </div>
{tag.count ? (
<sup className="relative ml-1">{tag.count}</sup>
) : (
<></>
)}
</div>
</Link>
)
})}
</div>
</div>
)
}
export default TagGroups

View File

@@ -0,0 +1,23 @@
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 duration-200 mr-2 py-1 px-2 text-xs whitespace-nowrap`}
>
<div className="font-light hover:scale-105 transition-all duration-200 text-xl dark:text-green-500 hover:bg-gray-100 dark:hover:bg-hexo-black-gray p-2">
{tag.name + (tag.count ? `(${tag.count})` : '')}{' '}
</div>
</Link>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import Link from 'next/link'
const TagItemMini = ({ tag, selected = false }) => {
return (
<Link
key={tag}
href={selected ? '/' : `/tag/${encodeURIComponent(tag.name)}`}
passHref
className={'inline-block rounded-xl py-0.5 mr-2'}
>
<div className="text-md font-bold text-shadow text-[#2EBF8B]">
{selected && <i className="mr-1 fa-tag" />}{' '}
{tag.name + (tag.count ? `(${tag.count})` : '')}{' '}
</div>
</Link>
)
}
export default TagItemMini

View File

@@ -0,0 +1,20 @@
import NotionIcon from '@/components/NotionIcon'
import { siteConfig } from '@/lib/config'
/**
* 标题栏
* @param {*} props
* @returns
*/
export const Title = (props) => {
const { post } = props
const title = post?.title || siteConfig('TITLE')
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">{siteConfig('POST_TITLE_ICON') && <NotionIcon icon={post?.pageIcon} />}{title}</h1>
<p className="leading-loose text-gray-dark">
{description}
</p>
</div>
}

18
themes/photo/config.js Normal file
View File

@@ -0,0 +1,18 @@
/**
* 主题配置文件
*/
const CONFIG = {
// 菜单配置
MOVIE_MENU_CATEGORY: true, // 显示分类
MOVIE_MENU_TAG: true, // 显示标签
MOVIE_MENU_ARCHIVE: true, // 显示归档
MOVIE_MENU_SEARCH: true, // 显示搜索
MOVIE_HOME_BACKGROUND: false, // 首页是否显示背景图, 默认关闭
MOVIE_ARTICLE_RECOMMEND: true, // 推荐关联内容在文章底部
MOVIE_VIDEO_COMBINE: true, // 聚合视频开启后一篇文章内的多个含caption的视频会被合并到文章开头并展示分集按钮
MOVIE_VIDEO_COMBINE_SHOW_PAGE_FORCE: false, // 即使只有一集也显示集数切换按钮
MOVIE_POST_LIST_COVER: true // 列表显示文章封面
}
export default CONFIG

496
themes/photo/index.js Normal file
View File

@@ -0,0 +1,496 @@
'use client'
import AlgoliaSearchModal from '@/components/AlgoliaSearchModal'
import Comment from '@/components/Comment'
import replaceSearchResult from '@/components/Mark'
import NotionPage from '@/components/NotionPage'
import ShareBar from '@/components/ShareBar'
import { siteConfig } from '@/lib/config'
import { useGlobal } from '@/lib/global'
import { loadWowJS } from '@/lib/plugins/wow'
import { isBrowser } from '@/lib/utils'
import { Transition } from '@headlessui/react'
import { useRouter } from 'next/router'
import { createContext, useContext, useEffect, useRef, useState } from 'react'
import Announcement from './components/Announcement'
import ArchiveDateList from './components/ArchiveDateList'
import ArticleFooter from './components/ArticleFooter'
import { ArticleHeader } from './components/ArticleInfo'
import { ArticleLock } from './components/ArticleLock'
import BlogListGroupByDate from './components/BlogListGroupByDate'
import BlogRecommend from './components/BlogRecommend'
import CategoryGroup from './components/CategoryGroup'
import CategoryItem from './components/CategoryItem'
import { Footer } from './components/Footer'
import { Header } from './components/Header'
import { HomeBackgroundImage } from './components/HomeBackgroundImage'
import JumpToTopButton from './components/JumpToTopButton'
import LatestPostsGroup from './components/LatestPostsGroup'
import SlotBar from './components/SlotBar'
import Swiper from './components/Swiper'
import TagGroups from './components/TagGroups'
import TagItem from './components/TagItem'
import CONFIG from './config'
import { Style } from './style'
// 主题全局状态
const ThemeGlobalPhoto = createContext()
export const usePhotoGlobal = () => useContext(ThemeGlobalPhoto)
/**
* 基础布局框架
* 1.其它页面都嵌入在LayoutBase中
* 2.采用左右两侧布局,移动端使用顶部导航栏
* @returns {JSX.Element}
* @constructor
*/
const LayoutBase = props => {
const { children, slotTop } = props
const { onLoading, fullWidth } = useGlobal()
const collapseRef = useRef(null)
const router = useRouter()
const searchModal = useRef(null)
const [expandMenu, updateExpandMenu] = useState(false)
useEffect(() => {
loadWowJS()
}, [])
// 首页背景图
const headerSlot =
router.route === '/' &&
siteConfig('MOVIE_HOME_BACKGROUND', null, CONFIG) ? (
<HomeBackgroundImage />
) : null
return (
<ThemeGlobalPhoto.Provider
value={{ searchModal, expandMenu, updateExpandMenu, collapseRef }}>
<div
id='theme-photo'
className={`${siteConfig('FONT_STYLE')} dark:text-gray-300 duration-300 transition-all bg-white dark:bg-[#2A2A2A] scroll-smooth min-h-screen flex flex-col justify-between`}>
<Style />
{/* 页头 */}
<Header {...props} />
{headerSlot}
{/* 主体 */}
<div id='container-inner' className='w-full relative flex-grow z-10'>
<div
id='container-wrapper'
className={
(JSON.parse(siteConfig('LAYOUT_SIDEBAR_REVERSE'))
? 'flex-row-reverse'
: '') + 'relative mx-auto justify-center md:flex items-start'
}>
{/* 内容 */}
<div className={`w-full ${fullWidth ? '' : ''} px-0`}>
<Transition
show={!onLoading}
appear={true}
enter='transition ease-in-out duration-700 transform order-first'
enterFrom='opacity-0 translate-y-16'
enterTo='opacity-100'
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>
</div>
</div>
{/* 页脚 */}
<Footer {...props} />
{/* 搜索框 */}
<AlgoliaSearchModal cRef={searchModal} {...props} />
{/* 回顶按钮 */}
<div className='fixed right-4 bottom-4 z-10'>
<JumpToTopButton />
</div>
</div>
</ThemeGlobalPhoto.Provider>
)
}
/**
* 首页
* @param {*} props
* @returns 此主题首页就是列表
*/
const LayoutIndex = props => {
return <LayoutPostList {...props} />
}
/**
* 文章列表
* @param {*} props
* @returns
*/
const LayoutPostList = props => {
return (
<div className='mx-auto'>
<SlotBar {...props} />
{/* 滑动组件 */}
<Swiper {...props} />
{/* 公告 */}
<Announcement {...props} className='mx-auto w-full max-w-5xl my-12' />
</div>
)
}
/**
* 文章详情页
* @param {*} props
* @returns
*/
const LayoutSlug = props => {
const { post, lock, validPassword } = props
const router = useRouter()
useEffect(() => {
// 用js 实现将页面中的多个视频聚合为一个分集的视频
function combineVideo() {
// 找到 id 为 notion-article 的元素
const notionArticle = document.getElementById('notion-article')
if (!notionArticle) return // 如果找不到对应的元素,则退出函数
// 找到所有的 .notion-asset-wrapper 元素
const assetWrappers = document.querySelectorAll('.notion-asset-wrapper')
if (!assetWrappers || assetWrappers.length === 0) return // 如果找不到对应的元素,则退出函数
// 不要重复创建
const exists = document.querySelectorAll('.video-wrapper')
if (exists && exists.length > 0) return
// 创建视频区块容器元素
const videoWrapper = document.createElement('div')
videoWrapper.className =
'video-wrapper py-1 px-3 bg-gray-100 dark:bg-white dark:text-black mx-auto'
// 创建走马灯封装容器元素
const carouselWrapper = document.createElement('div')
carouselWrapper.classList.add('notion-carousel-wrapper')
// 创建分集按钮figcaption文本的数组
const figCaptionValues = []
// 遍历所有 .notion-asset-wrapper 元素
assetWrappers.forEach((wrapper, index) => {
// 检查 .notion-asset-wrapper 元素是否有子元素 figcaption
const figCaption = wrapper.querySelector('figcaption')
// 检查 .notion-asset-wrapper 元素是否有 notion-asset-wrapper-video 或 notion-asset-wrapper-embed 类
if (
!wrapper.classList.contains('notion-asset-wrapper-video') &&
!wrapper.classList.contains('notion-asset-wrapper-embed')
)
return
if (!figCaption) return // 如果没有子元素 figcaption则不处理该元素
// 获取 figcaption 的文本内容并添加到数组中
const figCaptionValue = figCaption
? figCaption?.textContent?.trim()
: `P-${index}`
figCaptionValues.push(figCaptionValue)
// 创建一个新的 div 元素用于包裹当前的 .notion-asset-wrapper 元素
const carouselItem = document.createElement('div')
carouselItem.classList.add('notion-carousel')
carouselItem.appendChild(wrapper)
// 如有外链、保存在data-src中
const iframe = wrapper.querySelector('iframe')
if (iframe) {
iframe?.setAttribute('data-src', iframe?.getAttribute('src'))
}
// 如果是第一个元素,设置为 active
if (index === 0) {
carouselItem.classList.add('active')
} else {
iframe?.setAttribute('src', '')
}
// 将元素添加到容器中
carouselWrapper.appendChild(carouselItem)
// 从 DOM 中移除原始的 .notion-asset-wrapper 元素
// wrapper.parentNode.removeChild(wrapper)
})
// 创建一个用于保存 figcaption 值的容器元素
const figCaptionWrapper = document.createElement('div')
figCaptionWrapper.className =
'notion-carousel-route py-2 max-h-36 overflow-y-auto'
// 遍历 figCaptionValues 数组,并将每个值添加到容器元素中
figCaptionValues.forEach(value => {
const div = document.createElement('div')
div.textContent = value
div.addEventListener('click', function () {
// 遍历所有的 carouselItem 元素
document.querySelectorAll('.notion-carousel').forEach(item => {
// 外链保存在data-src中
const iframe = item.querySelector('iframe')
// 判断当前元素是否包含该 figCaption 的文本内容,如果是则设置为 active否则取消 active
if (item.querySelector('figcaption').textContent.trim() === value) {
item.classList.add('active')
if (iframe) {
iframe.setAttribute('src', iframe.getAttribute('data-src'))
}
} else {
item.classList.remove('active')
// 不活跃窗口暂停播放仅支持notion上传视频、不支持外链
item.querySelectorAll('video')?.forEach(video => {
video.pause()
})
// 外链通过设置src来实现视频暂停播放
if (iframe) {
iframe.setAttribute('src', '')
}
}
})
})
figCaptionWrapper.appendChild(div)
})
if (carouselWrapper.children.length > 0) {
// 将包含 figcaption 值的容器元素添加到 notion-article 的第一个子元素插入
videoWrapper.appendChild(carouselWrapper)
// 显示分集按钮 大于1集才显示 ;或者用户 要求强制显示
if (
figCaptionWrapper.children.length > 1 ||
siteConfig('MOVIE_VIDEO_COMBINE_SHOW_PAGE_FORCE', false, CONFIG)
) {
videoWrapper.appendChild(figCaptionWrapper)
}
// 放入页面
if (
notionArticle.firstChild &&
notionArticle.contains(notionArticle.firstChild)
) {
notionArticle.insertBefore(videoWrapper, notionArticle.firstChild)
} else {
notionArticle.appendChild(videoWrapper)
}
}
}
setTimeout(() => {
combineVideo()
}, 1500)
// 404
if (!post) {
setTimeout(
() => {
if (isBrowser) {
const article = document.getElementById('notion-article')
if (!article) {
router.push('/404').then(() => {
console.warn('找不到页面', router.asPath)
})
}
}
},
siteConfig('POST_WAITING_TIME_FOR_404') * 1000
)
}
return () => {
// 获取所有 class="video-wrapper" 的元素
const videoWrappers = document.querySelectorAll('.video-wrapper')
// 遍历所有匹配的元素并移除它们
videoWrappers.forEach(wrapper => {
wrapper.parentNode.removeChild(wrapper) // 从 DOM 中移除元素
})
}
}, [post])
return (
<>
{!lock ? (
<div
id='article-wrapper'
className='px-2 max-w-5xl 2xl:max-w-[70%] mx-auto'>
{/* 标题 */}
<ArticleHeader post={post} />
{/* 页面元素 */}
<NotionPage post={post} />
{/* 文章页脚 */}
<ArticleFooter post={post} />
{/* 推荐 */}
<BlogRecommend {...props} />
{/* 分享栏目 */}
<ShareBar post={post} />
{/* 评论区 */}
<Comment frontMatter={post} />
</div>
) : (
<ArticleLock validPassword={validPassword} />
)}
</>
)
}
/**
* 404页
* @param {*} props
* @returns
*/
const Layout404 = props => {
const { locale } = useGlobal()
const { searchModal } = usePhotoGlobal()
const router = useRouter()
// 展示搜索框
const toggleShowSearchInput = () => {
if (siteConfig('ALGOLIA_APP_ID')) {
searchModal.current.openSearch()
}
}
const onKeyUp = e => {
if (e.keyCode === 13) {
const search = document.getElementById('search').value
if (search) {
router.push({ pathname: '/search/' + search })
}
}
}
return (
<>
<div className='h-52'>
<h2 className='text-4xl'>{locale.COMMON.NO_RESULTS_FOUND}</h2>
<hr className='my-4' />
<div className='max-w-md relative'>
<input
autoFocus
id='search'
onClick={toggleShowSearchInput}
onKeyUp={onKeyUp}
className='float-left w-full outline-none h-full p-2 rounded dark:bg-[#383838] bg-gray-100'
aria-label='Submit search'
type='search'
name='s'
autoComplete='off'
placeholder='Type then hit enter to search...'
/>
<i className='fas fa-search absolute right-0 my-auto p-2'></i>
</div>
</div>
{/* 底部导航 */}
<div className='h-full flex-grow grid grid-cols-4 gap-4'>
<LatestPostsGroup {...props} />
<CategoryGroup {...props} />
<ArchiveDateList {...props} />
<TagGroups {...props} />
</div>
</>
)
}
/**
* 搜索页
* @param {*} props
* @returns
*/
const LayoutSearch = props => {
const { keyword } = props
const router = useRouter()
useEffect(() => {
if (isBrowser) {
// 高亮搜索到的结果
const container = document.getElementById('posts-wrapper')
if (keyword && container) {
replaceSearchResult({
doms: container,
search: keyword,
target: {
element: 'span',
className: 'text-red-500 border-b border-dashed'
}
})
}
}
}, [router])
return <LayoutPostList {...props} />
}
/**
* 归档列表
* @param {*} props
* @returns 按照日期将文章分组排序
*/
const LayoutArchive = props => {
const { archivePosts } = props
return (
<>
<div className='mb-10 pb-20 md:py-12 p-3 min-h-screen w-full max-w-5xl 2xl:max-w-[70%] mx-auto'>
{Object.keys(archivePosts).map(archiveTitle => (
<BlogListGroupByDate
key={archiveTitle}
archiveTitle={archiveTitle}
archivePosts={archivePosts}
/>
))}
</div>
</>
)
}
/**
* 分类列表
* @param {*} props
* @returns
*/
const LayoutCategoryIndex = props => {
const { categoryOptions } = props
return (
<>
<div id='category-list' className='duration-200 flex flex-wrap'>
{categoryOptions?.map(category => (
<CategoryItem key={category.name} category={category} />
))}
</div>
</>
)
}
/**
* 标签列表
* @param {*} props
* @returns
*/
const LayoutTagIndex = props => {
const { tagOptions } = props
return (
<>
<div id='tags-list' className='duration-200 flex flex-wrap'>
{tagOptions.map(tag => (
<TagItem key={tag.name} tag={tag} />
))}
</div>
</>
)
}
export {
Layout404,
LayoutArchive,
LayoutBase,
LayoutCategoryIndex,
LayoutIndex,
LayoutPostList,
LayoutSearch,
LayoutSlug,
LayoutTagIndex,
CONFIG as THEME_CONFIG
}

30
themes/photo/style.js Normal file
View File

@@ -0,0 +1,30 @@
/* eslint-disable react/no-unknown-property */
/**
* 此处样式只对当前主题生效
* 此处不支持tailwindCSS的 @apply 语法
* @returns
*/
const Style = () => {
return (
<style jsx global>{`
// 底色
.dark body {
background-color: black;
}
// 毛玻璃背景色
.bg-glassmorphism {
background: hsla(0, 0%, 100%, 0.4);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.dark .bg-glassmorphism {
background: hsla(0, 0%, 0%, 0.4);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
`}</style>
)
}
export { Style }