diff --git a/.env.local b/.env.local
index af3724bc..6952a616 100644
--- a/.env.local
+++ b/.env.local
@@ -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
# 可在此添加环境变量,去掉最左边的(# )注释即可
diff --git a/lib/db/getSiteData.js b/lib/db/getSiteData.js
index 951bdaac..4d615824 100755
--- a/lib/db/getSiteData.js
+++ b/lib/db/getSiteData.js
@@ -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 })
diff --git a/package.json b/package.json
index 76f6bf87..dff2f46a 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/public/images/themes-preview/commerce.png b/public/images/themes-preview/commerce.png
index a82980e3..a019ee3f 100644
Binary files a/public/images/themes-preview/commerce.png and b/public/images/themes-preview/commerce.png differ
diff --git a/public/images/themes-preview/example.png b/public/images/themes-preview/example.png
index 213e9d0b..ee8a8d78 100644
Binary files a/public/images/themes-preview/example.png and b/public/images/themes-preview/example.png differ
diff --git a/public/images/themes-preview/fukasawa.png b/public/images/themes-preview/fukasawa.png
index 155e7480..c892d1eb 100644
Binary files a/public/images/themes-preview/fukasawa.png and b/public/images/themes-preview/fukasawa.png differ
diff --git a/public/images/themes-preview/game.png b/public/images/themes-preview/game.png
index 66ff7efd..5359450d 100644
Binary files a/public/images/themes-preview/game.png and b/public/images/themes-preview/game.png differ
diff --git a/public/images/themes-preview/heo.png b/public/images/themes-preview/heo.png
index 19fcb1fa..73dfc1bb 100644
Binary files a/public/images/themes-preview/heo.png and b/public/images/themes-preview/heo.png differ
diff --git a/public/images/themes-preview/hexo.png b/public/images/themes-preview/hexo.png
index fa3e86f8..89d3aa0a 100644
Binary files a/public/images/themes-preview/hexo.png and b/public/images/themes-preview/hexo.png differ
diff --git a/public/images/themes-preview/landing.png b/public/images/themes-preview/landing.png
index cdbec697..e39f9afa 100644
Binary files a/public/images/themes-preview/landing.png and b/public/images/themes-preview/landing.png differ
diff --git a/public/images/themes-preview/magzine.png b/public/images/themes-preview/magzine.png
index cdbec697..e39f9afa 100644
Binary files a/public/images/themes-preview/magzine.png and b/public/images/themes-preview/magzine.png differ
diff --git a/public/images/themes-preview/matery.png b/public/images/themes-preview/matery.png
index 82c433b3..d0724280 100644
Binary files a/public/images/themes-preview/matery.png and b/public/images/themes-preview/matery.png differ
diff --git a/public/images/themes-preview/medium.png b/public/images/themes-preview/medium.png
index a3bf7468..c8cd22b9 100644
Binary files a/public/images/themes-preview/medium.png and b/public/images/themes-preview/medium.png differ
diff --git a/public/images/themes-preview/movie.png b/public/images/themes-preview/movie.png
index 6eea5fd5..3af8a76e 100644
Binary files a/public/images/themes-preview/movie.png and b/public/images/themes-preview/movie.png differ
diff --git a/public/images/themes-preview/photo.png b/public/images/themes-preview/photo.png
new file mode 100644
index 00000000..f2e074ae
Binary files /dev/null and b/public/images/themes-preview/photo.png differ
diff --git a/public/images/themes-preview/plog.png b/public/images/themes-preview/plog.png
index 833bd76a..ddaccca7 100644
Binary files a/public/images/themes-preview/plog.png and b/public/images/themes-preview/plog.png differ
diff --git a/public/images/themes-preview/starter.png b/public/images/themes-preview/starter.png
index d3a110ea..c4ec9487 100644
Binary files a/public/images/themes-preview/starter.png and b/public/images/themes-preview/starter.png differ
diff --git a/styles/globals.css b/styles/globals.css
index 7c215c46..5c86cecf 100644
--- a/styles/globals.css
+++ b/styles/globals.css
@@ -2,6 +2,10 @@
@tailwind components;
@tailwind utilities;
+html {
+ overflow-x: hidden;
+}
+
.wrapper {
min-height: 100vh;
display: flex;
diff --git a/themes/photo/components/Announcement.js b/themes/photo/components/Announcement.js
new file mode 100644
index 00000000..38b701a5
--- /dev/null
+++ b/themes/photo/components/Announcement.js
@@ -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 (
+
+ )
+}
+export default Announcement
diff --git a/themes/photo/components/ArchiveDateList.js b/themes/photo/components/ArchiveDateList.js
new file mode 100644
index 00000000..cd389028
--- /dev/null
+++ b/themes/photo/components/ArchiveDateList.js
@@ -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 (
+
+
{locale.NAV.ARCHIVE}
+ {dates?.map((date, index) => {
+ return (
+
+
+ {date}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/themes/photo/components/ArticleFooter.js b/themes/photo/components/ArticleFooter.js
new file mode 100644
index 00000000..9a928cce
--- /dev/null
+++ b/themes/photo/components/ArticleFooter.js
@@ -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 (
+ <>
+ {/* 分类和标签部分 */}
+
+ {/* 分类标签(如果文章不是“页面”类型) */}
+ {post?.type !== 'Page' && (
+ <>
+
+ {post?.category}
+
+ >
+ )}
+
+ {/* 标签部分(若文章有标签) */}
+
+ {post?.tags?.length > 0 && (
+ <>
+ {locale.COMMON.TAGS} :
+ >
+ )}
+ {/* 显示所有标签 */}
+ {post?.tags?.map(tag => {
+ return (
+
+ {tag}
+
+ )
+ })}
+
+
+
+ {/* 发布日期信息 */}
+ {/* 将发布日期移至文章底部并设置样式 */}
+
+
+ {post?.publishDay}
+
+
+ >
+ )
+}
diff --git a/themes/photo/components/ArticleInfo.js b/themes/photo/components/ArticleInfo.js
new file mode 100644
index 00000000..c0cd89e0
--- /dev/null
+++ b/themes/photo/components/ArticleInfo.js
@@ -0,0 +1,23 @@
+/**
+ * 文章页头
+ * @param {*} props
+ * @returns
+ */
+export const ArticleHeader = props => {
+ const { post } = props
+
+ return (
+
+ {/* 标题部分 */}
+ {/* 将标题字体大小设置为 16px,并将字体粗细设置为细体 */}
+
+ {post?.title}
+
+
+ )
+}
diff --git a/themes/photo/components/ArticleLock.js b/themes/photo/components/ArticleLock.js
new file mode 100644
index 00000000..3744c183
--- /dev/null
+++ b/themes/photo/components/ArticleLock.js
@@ -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 = `${locale.COMMON.PASSWORD_ERROR}
`
+ }
+ }
+ }
+ const passwordInputRef = useRef(null)
+ useEffect(() => {
+ // 选中密码输入框并将其聚焦
+ passwordInputRef.current.focus()
+ }, [])
+
+ return
+
+
{locale.COMMON.ARTICLE_LOCK_TIPS}
+
+
{
+ if (e.key === 'Enter') {
+ submitPassword()
+ }
+ }}
+ ref={passwordInputRef} // 绑定ref到passwordInputRef变量
+ className='outline-none w-full text-sm pl-5 rounded-l transition focus:shadow-lg font-light leading-10 text-black dark:bg-gray-500 bg-gray-50'
+ >
+
+ {locale.COMMON.SUBMIT}
+
+
+
+
+
+
+}
diff --git a/themes/photo/components/BlogListGroupByDate.js b/themes/photo/components/BlogListGroupByDate.js
new file mode 100644
index 00000000..ebc30aa5
--- /dev/null
+++ b/themes/photo/components/BlogListGroupByDate.js
@@ -0,0 +1,36 @@
+import Link from 'next/link'
+
+/**
+ * 按照日期将文章分组
+ * 归档页面用到
+ * @param {*} param0
+ * @returns
+ */
+export default function BlogListGroupByDate({ archiveTitle, archivePosts }) {
+ return (
+
+
+ {archiveTitle}
+
+
+
+
+ )
+}
diff --git a/themes/photo/components/BlogListPage.js b/themes/photo/components/BlogListPage.js
new file mode 100644
index 00000000..2a3126ca
--- /dev/null
+++ b/themes/photo/components/BlogListPage.js
@@ -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 (
+
+
+ {posts?.map(post => (
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/themes/photo/components/BlogListScroll.js b/themes/photo/components/BlogListScroll.js
new file mode 100644
index 00000000..aa1538c7
--- /dev/null
+++ b/themes/photo/components/BlogListScroll.js
@@ -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 (
+
+ {postsToShow?.map(post => (
+
+ ))}
+
+
+ {' '}
+ {hasMore ? locale.COMMON.MORE : `${locale.COMMON.NO_MORE} 😰`}{' '}
+
+
+ )
+}
diff --git a/themes/photo/components/BlogPostCard.js b/themes/photo/components/BlogPostCard.js
new file mode 100644
index 00000000..771f31b5
--- /dev/null
+++ b/themes/photo/components/BlogPostCard.js
@@ -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 (
+
+
+ {/* 固定高度 ,空白用图片拉升填充 */}
+
+ {/* 图片 填充卡片 */}
+
+
+
+
+
+ {post?.tagItems && post?.tagItems.length > 0 && (
+ <>
+
+ {post.tagItems.map(tag => (
+
+ ))}
+
+ >
+ )}
+
+ {/* 阴影遮罩 */}
+
+ {siteConfig('POST_TITLE_ICON') && (
+
+ )}
+ {post.title}
+
+
+
+ {post?.summary}
+
+
+
+
+
+
+ )
+}
+
+export default BlogPostCard
diff --git a/themes/photo/components/BlogRecommend.js b/themes/photo/components/BlogRecommend.js
new file mode 100644
index 00000000..ef87030e
--- /dev/null
+++ b/themes/photo/components/BlogRecommend.js
@@ -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 (
+
+
+
+
+ {locale.COMMON.RELATE_POSTS}
+
+
+
+ {recommendPosts.map(post => {
+ const headerImage = post?.pageCoverThumbnail
+ ? post.pageCoverThumbnail
+ : siteInfo?.pageCover
+
+ return (
+
+
+
+ {/* 卡片的阴影遮罩,为了凸显图片上的文字 */}
+
+
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/themes/photo/components/CategoryGroup.js b/themes/photo/components/CategoryGroup.js
new file mode 100644
index 00000000..63c3917c
--- /dev/null
+++ b/themes/photo/components/CategoryGroup.js
@@ -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 (
+ <>
+
+
{locale.COMMON.CATEGORY}
+
+ {categories.map(category => {
+ const selected = currentCategory === category.name
+ return (
+
+
+ {category.name}({category.count})
+
+ )
+ })}
+
+
+ >
+ )
+}
+
+export default CategoryGroup
diff --git a/themes/photo/components/CategoryItem.js b/themes/photo/components/CategoryItem.js
new file mode 100644
index 00000000..1190965b
--- /dev/null
+++ b/themes/photo/components/CategoryItem.js
@@ -0,0 +1,20 @@
+import Link from 'next/link'
+
+/**
+ * 文章分类
+ * @param {*} param0
+ * @returns
+ */
+export default function CategoryItem({ category }) {
+ return (
+
+
+ {category.name}({category.count})
+
+
+ )
+}
diff --git a/themes/photo/components/ExampleRecentComments.js b/themes/photo/components/ExampleRecentComments.js
new file mode 100644
index 00000000..9dbdfa7f
--- /dev/null
+++ b/themes/photo/components/ExampleRecentComments.js
@@ -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 && Loading...
}
+ {!onLoading && comments && comments.length === 0 && No Comments
}
+ {!onLoading && comments && comments.length > 0 && comments.map((comment) => )}
+
+ >
+}
+
+export default ExampleRecentComments
diff --git a/themes/photo/components/Footer.js b/themes/photo/components/Footer.js
new file mode 100644
index 00000000..dc361e96
--- /dev/null
+++ b/themes/photo/components/Footer.js
@@ -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 (
+
+ )
+}
diff --git a/themes/photo/components/Header.js b/themes/photo/components/Header.js
new file mode 100644
index 00000000..29b7bee0
--- /dev/null
+++ b/themes/photo/components/Header.js
@@ -0,0 +1,27 @@
+import { siteConfig } from '@/lib/config'
+import Link from 'next/link'
+import MenuHierarchical from './MenuHierarchical'
+
+/**
+ * 网站顶部
+ * @returns
+ */
+export const Header = props => {
+ return (
+ <>
+
+ {/* 左侧Logo */}
+
+ {siteConfig('TITLE')}
+
+
+ {/* 右侧使用一个三级菜单 */}
+
+
+
+
+ >
+ )
+}
diff --git a/themes/photo/components/HomeBackgroundImage.js b/themes/photo/components/HomeBackgroundImage.js
new file mode 100644
index 00000000..4cdb5056
--- /dev/null
+++ b/themes/photo/components/HomeBackgroundImage.js
@@ -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 (
+
+ )
+}
diff --git a/themes/photo/components/JumpToTopButton.js b/themes/photo/components/JumpToTopButton.js
new file mode 100644
index 00000000..f5e22b61
--- /dev/null
+++ b/themes/photo/components/JumpToTopButton.js
@@ -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 window.scrollTo({ top: 0, behavior: 'smooth' })}
+ >
+
+}
+
+export default JumpToTopButton
diff --git a/themes/photo/components/LatestPostsGroup.js b/themes/photo/components/LatestPostsGroup.js
new file mode 100644
index 00000000..40366e37
--- /dev/null
+++ b/themes/photo/components/LatestPostsGroup.js
@@ -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 (
+
+
+
+
+ {locale.COMMON.LATEST_POSTS}
+
+
+
+ {latestPosts.map(post => {
+ const selected =
+ currentPath === `${siteConfig('SUB_PATH', '')}/${post.slug}`
+
+ return (
+
+
+
{post.title}
+
+
+ )
+ })}
+
+ )
+}
+export default LatestPostsGroup
diff --git a/themes/photo/components/LoadingCover.js b/themes/photo/components/LoadingCover.js
new file mode 100644
index 00000000..75976180
--- /dev/null
+++ b/themes/photo/components/LoadingCover.js
@@ -0,0 +1,8 @@
+
+export default function LoadingCover() {
+ return
+}
diff --git a/themes/photo/components/MenuHierarchical.js b/themes/photo/components/MenuHierarchical.js
new file mode 100644
index 00000000..979a6ca3
--- /dev/null
+++ b/themes/photo/components/MenuHierarchical.js
@@ -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 (
+
+ {/* 菜单按钮 */}
+
+ {title}
+
+
+ {/* 移动端菜单 */}
+
+
+ {/* 遮罩 */}
+ {isOpen && (
+
+ )}
+
+ )
+}
diff --git a/themes/photo/components/MenuItemCollapse.js b/themes/photo/components/MenuItemCollapse.js
new file mode 100644
index 00000000..73458f13
--- /dev/null
+++ b/themes/photo/components/MenuItemCollapse.js
@@ -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 (
+ <>
+
+ {!hasSubMenu && (
+
+
+ {link?.icon && }
+ {link?.name}
+
+
+ )}
+ {hasSubMenu && (
+
+
+ {link?.icon && }
+ {link?.name}
+
+
+
+ )}
+
+
+ {/* 折叠子菜单 */}
+ {hasSubMenu && (
+
+ {link.subMenus.map((sLink, index) => {
+ return (
+
+
+
+ {link?.icon && }{' '}
+ {sLink.title}
+
+
+
+ )
+ })}
+
+ )}
+ >
+ )
+}
diff --git a/themes/photo/components/MenuItemDrop.js b/themes/photo/components/MenuItemDrop.js
new file mode 100644
index 00000000..3b5cf813
--- /dev/null
+++ b/themes/photo/components/MenuItemDrop.js
@@ -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 (
+ changeShow(true)}
+ onMouseOut={() => changeShow(false)}>
+ {!hasSubMenu && (
+
+ {link?.icon &&
} {link?.name}
+ {hasSubMenu &&
}
+
+ )}
+
+ {hasSubMenu && (
+ <>
+
+ {link?.icon && } {link?.name}
+
+
+ >
+ )}
+
+ {/* 子菜单 */}
+ {hasSubMenu && (
+
+ {link.subMenus.map((sLink, index) => {
+ return (
+ -
+
+
+ {link?.icon && }
+ {sLink.title}
+
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/themes/photo/components/NormalMenuItem.js b/themes/photo/components/NormalMenuItem.js
new file mode 100644
index 00000000..2ed6f2e9
--- /dev/null
+++ b/themes/photo/components/NormalMenuItem.js
@@ -0,0 +1,20 @@
+import Link from 'next/link'
+
+/**
+ * 旧的普通菜单
+ * @param {*} props
+ * @returns
+ */
+export const NormalMenuItem = props => {
+ const { link } = props
+ return (
+ link?.show && (
+
+ {link.name}
+
+ )
+ )
+}
diff --git a/themes/photo/components/PaginationNumber.js b/themes/photo/components/PaginationNumber.js
new file mode 100644
index 00000000..64323b9c
--- /dev/null
+++ b/themes/photo/components/PaginationNumber.js
@@ -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端分页按钮 */}
+
+ {/* 上一页 */}
+
+
+
+
+ {locale.PAGINATION.PREV}
+
+
+
+
+ {/* 分页 */}
+
+ {pages}
+
+ {/* 跳转页码 */}
+
+
+
+ {/* 下一页 */}
+
+
+
+
+ {locale.PAGINATION.NEXT}
+
+
+
+
+
+ {/* 移动端分页 */}
+
+
+ {/* 上一页 */}
+
+ {locale.PAGINATION.PREV}
+
+
+ {showPrev && showNext &&
}
+
+ {/* 下一页 */}
+
+ {locale.PAGINATION.NEXT}
+
+
+ >
+ )
+}
+
+/**
+ * 页码按钮
+ * @param {*} page
+ * @param {*} currentPage
+ * @param {*} pagePrefix
+ * @returns
+ */
+function getPageElement(page, currentPage, pagePrefix) {
+ const selected = page + '' === currentPage + ''
+ if (!page) {
+ return <>>
+ }
+ return (
+
+ {page}
+
+ )
+}
+
+/**
+ * 获取所有页码
+ * @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(
+
+ ...{' '}
+
+ )
+ }
+
+ for (let i = 0; i < dynamicGroupCount; i++) {
+ if (startPage + i < totalPage) {
+ pages.push(getPageElement(startPage + i, page, pagePrefix))
+ }
+ }
+
+ if (startPage + dynamicGroupCount < totalPage) {
+ pages.push(...
)
+ }
+
+ pages.push(getPageElement(totalPage, page, pagePrefix))
+ }
+ return pages
+}
+export default PaginationNumber
diff --git a/themes/photo/components/PostItemCard.js b/themes/photo/components/PostItemCard.js
new file mode 100644
index 00000000..41b57f5b
--- /dev/null
+++ b/themes/photo/components/PostItemCard.js
@@ -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 (
+
+
+
+
+
+
+
+
+
+ {siteConfig('POST_TITLE_ICON') && (
+
+ )}
+ {post?.title}
+
+
+
+ {/* 发布日期 */}
+
+ {formatDateFmt(post?.publishDate, 'yyyy-MM')}
+
+
+
+
+ )
+}
+
+export default PostItemCard
diff --git a/themes/photo/components/SearchInput.js b/themes/photo/components/SearchInput.js
new file mode 100644
index 00000000..4f375d4e
--- /dev/null
+++ b/themes/photo/components/SearchInput.js
@@ -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
+}
+
+export default SearchInput
diff --git a/themes/photo/components/SideBar.js b/themes/photo/components/SideBar.js
new file mode 100644
index 00000000..ea0531b7
--- /dev/null
+++ b/themes/photo/components/SideBar.js
@@ -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 (
+
+
+
+
+
+
+
+
+ {siteConfig('COMMENT_WALINE_SERVER_URL') && siteConfig('COMMENT_WALINE_RECENT') && }
+
+
+
+
+ )
+}
diff --git a/themes/photo/components/SlotBar.js b/themes/photo/components/SlotBar.js
new file mode 100644
index 00000000..e3bed727
--- /dev/null
+++ b/themes/photo/components/SlotBar.js
@@ -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 (
+
+
+
+ {locale.COMMON.TAGS} : {tag}{' '}
+
+
+
+
+ )
+ } else if (category) {
+ return (
+
+
+ {locale.COMMON.CATEGORY} : {category}
+
+
+
+ )
+ }
+ return <>>
+}
diff --git a/themes/photo/components/Swiper.js b/themes/photo/components/Swiper.js
new file mode 100644
index 00000000..074f915c
--- /dev/null
+++ b/themes/photo/components/Swiper.js
@@ -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 (
+
+ {/* Carousel items */}
+
+
+ {posts &&
+ posts?.map((post, index) => (
+
+ ))}
+
+ )
+}
+
+export default InertiaCarousel
diff --git a/themes/photo/components/TagGroups.js b/themes/photo/components/TagGroups.js
new file mode 100644
index 00000000..597cddc1
--- /dev/null
+++ b/themes/photo/components/TagGroups.js
@@ -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 (
+
+
{locale.COMMON.TAGS}
+
+
+ )
+}
+
+export default TagGroups
diff --git a/themes/photo/components/TagItem.js b/themes/photo/components/TagItem.js
new file mode 100644
index 00000000..c608c8cb
--- /dev/null
+++ b/themes/photo/components/TagItem.js
@@ -0,0 +1,23 @@
+import Link from 'next/link'
+
+/**
+ * 标签
+ * @param {*} param0
+ * @returns
+ */
+export default function TagItem({ tag }) {
+ return (
+
+
+
+ {tag.name + (tag.count ? `(${tag.count})` : '')}{' '}
+
+
+
+ )
+}
diff --git a/themes/photo/components/TagItemMini.js b/themes/photo/components/TagItemMini.js
new file mode 100644
index 00000000..a4b7da1c
--- /dev/null
+++ b/themes/photo/components/TagItemMini.js
@@ -0,0 +1,19 @@
+import Link from 'next/link'
+
+const TagItemMini = ({ tag, selected = false }) => {
+ return (
+
+
+ {selected && }{' '}
+ {tag.name + (tag.count ? `(${tag.count})` : '')}{' '}
+
+
+ )
+}
+
+export default TagItemMini
diff --git a/themes/photo/components/Title.js b/themes/photo/components/Title.js
new file mode 100644
index 00000000..a073f1cc
--- /dev/null
+++ b/themes/photo/components/Title.js
@@ -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
+
{siteConfig('POST_TITLE_ICON') && }{title}
+
+ {description}
+
+
+}
diff --git a/themes/photo/config.js b/themes/photo/config.js
new file mode 100644
index 00000000..b6b0bd9c
--- /dev/null
+++ b/themes/photo/config.js
@@ -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
diff --git a/themes/photo/index.js b/themes/photo/index.js
new file mode 100644
index 00000000..f369e6fb
--- /dev/null
+++ b/themes/photo/index.js
@@ -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) ? (
+
+ ) : null
+
+ return (
+
+
+
+
+ {/* 页头 */}
+
+ {headerSlot}
+
+ {/* 主体 */}
+
+
+ {/* 内容 */}
+
+
+ {/* 嵌入模块 */}
+ {slotTop}
+ {children}
+
+
+
+
+
+ {/* 页脚 */}
+
+
+ {/* 搜索框 */}
+
+
+ {/* 回顶按钮 */}
+
+
+
+
+
+ )
+}
+
+/**
+ * 首页
+ * @param {*} props
+ * @returns 此主题首页就是列表
+ */
+const LayoutIndex = props => {
+ return
+}
+
+/**
+ * 文章列表
+ * @param {*} props
+ * @returns
+ */
+const LayoutPostList = props => {
+ return (
+
+
+ {/* 滑动组件 */}
+
+ {/* 公告 */}
+
+
+ )
+}
+
+/**
+ * 文章详情页
+ * @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 ? (
+
+ {/* 标题 */}
+
+ {/* 页面元素 */}
+
+ {/* 文章页脚 */}
+
+ {/* 推荐 */}
+
+ {/* 分享栏目 */}
+
+ {/* 评论区 */}
+
+
+ ) : (
+
+ )}
+ >
+ )
+}
+
+/**
+ * 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 (
+ <>
+
+
{locale.COMMON.NO_RESULTS_FOUND}
+
+
+
+
+
+
+ {/* 底部导航 */}
+
+ >
+ )
+}
+
+/**
+ * 搜索页
+ * @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
+}
+
+/**
+ * 归档列表
+ * @param {*} props
+ * @returns 按照日期将文章分组排序
+ */
+const LayoutArchive = props => {
+ const { archivePosts } = props
+ return (
+ <>
+
+ {Object.keys(archivePosts).map(archiveTitle => (
+
+ ))}
+
+ >
+ )
+}
+
+/**
+ * 分类列表
+ * @param {*} props
+ * @returns
+ */
+const LayoutCategoryIndex = props => {
+ const { categoryOptions } = props
+ return (
+ <>
+
+ {categoryOptions?.map(category => (
+
+ ))}
+
+ >
+ )
+}
+
+/**
+ * 标签列表
+ * @param {*} props
+ * @returns
+ */
+const LayoutTagIndex = props => {
+ const { tagOptions } = props
+ return (
+ <>
+
+ {tagOptions.map(tag => (
+
+ ))}
+
+ >
+ )
+}
+
+export {
+ Layout404,
+ LayoutArchive,
+ LayoutBase,
+ LayoutCategoryIndex,
+ LayoutIndex,
+ LayoutPostList,
+ LayoutSearch,
+ LayoutSlug,
+ LayoutTagIndex,
+ CONFIG as THEME_CONFIG
+}
diff --git a/themes/photo/style.js b/themes/photo/style.js
new file mode 100644
index 00000000..9169df0e
--- /dev/null
+++ b/themes/photo/style.js
@@ -0,0 +1,30 @@
+/* eslint-disable react/no-unknown-property */
+/**
+ * 此处样式只对当前主题生效
+ * 此处不支持tailwindCSS的 @apply 语法
+ * @returns
+ */
+const Style = () => {
+ return (
+
+ )
+}
+
+export { Style }